bookmark feature rewrite

This commit is contained in:
Fabian Freund
2026-07-30 07:20:23 +02:00
parent 3a942a57a6
commit 33dfcf890c
46 changed files with 8107 additions and 4716 deletions
@@ -82,7 +82,9 @@ AppLinkPolicySnapshot? appLinkPolicySnapshot(Ref ref) {
final isolationLoaded = ref
.watch(watchIsolatedContextContainerMapProvider)
.hasValue;
final strictLoaded = ref.watch(watchStrictContextAssignmentsProvider).hasValue;
final strictLoaded = ref
.watch(watchStrictContextAssignmentsProvider)
.hasValue;
final sitesLoaded = ref.watch(watchAllAssignedSitesProvider).hasValue;
// The real proxy-routing settings drive `protectGeneralContext`; the
// `...WithDefaults` view silently substitutes defaults while the row loads,
@@ -120,7 +120,7 @@ final class AppLinkPolicySnapshotProvider
}
String _$appLinkPolicySnapshotHash() =>
r'7f700b67d3b7b0b435fe82a98de455c6e374a1a2';
r'6fe2dca118d7162561fc7f6280d1a0411d50972a';
/// Single serialised writer that mirrors the Dart-owned app-link policy to the
/// native profile-scoped store (§2.8), the sole policy source consulted by the
@@ -311,7 +311,10 @@ AppLinkProtection computeAppLinkProtection({
final protectedContextIds = <String>{};
for (final MapEntry(:key, :value) in assignmentByContextId.entries) {
if (isAssignmentProtected(value, protectGeneralContext: protectGeneralContext)) {
if (isAssignmentProtected(
value,
protectGeneralContext: protectGeneralContext,
)) {
protectedContextIds.add(key);
}
}
@@ -323,7 +326,10 @@ AppLinkProtection computeAppLinkProtection({
.toList();
if (assignments.isEmpty) continue;
final chosen = resolveIsolationContextRouting(assignments).chosen;
if (isAssignmentProtected(chosen, protectGeneralContext: protectGeneralContext)) {
if (isAssignmentProtected(
chosen,
protectGeneralContext: protectGeneralContext,
)) {
protectedContextIds.add(key);
}
}
@@ -53,17 +53,17 @@ class ContainerAppLinkSettingsDialog extends ConsumerWidget {
WidgetRef ref,
ContextAppLinkPolicy Function(ContextAppLinkPolicy current) update,
) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save((current) {
final existing =
current.appLinkContextOverrides[contextId] ??
ContextAppLinkPolicy.blank();
return current.copyWith.appLinkContextOverrides({
...current.appLinkContextOverrides,
contextId: update(existing),
});
});
await ref.read(saveGeneralSettingsControllerProvider.notifier).save((
current,
) {
final existing =
current.appLinkContextOverrides[contextId] ??
ContextAppLinkPolicy.blank();
return current.copyWith.appLinkContextOverrides({
...current.appLinkContextOverrides,
contextId: update(existing),
});
});
}
@override
@@ -120,7 +120,9 @@ class ContainerAppLinkSettingsDialog extends ConsumerWidget {
RadioListTile.adaptive(
value: AppLinksMode.ask,
title: Text('Ask before opening'),
subtitle: Text('Show a prompt before opening links in apps'),
subtitle: Text(
'Show a prompt before opening links in apps',
),
),
RadioListTile.adaptive(
value: AppLinksMode.never,
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/// Parser output for bookmark imports.
///
/// This is the hand-off between parsing (which is pure, and runs in a
/// background isolate) and insertion (which talks to Places). Nodes carry no
/// guids and no explicit positions: the tree is described purely by nesting and
/// child order, and storage assigns both while inserting.
///
/// Netscape/Firefox metadata that Places cannot represent — `TAGS`,
/// `SHORTCUTURL`/keyword, `POST_DATA` and `LAST_CHARSET` — is intentionally
/// dropped during parsing rather than modelled here.
library;
/// A single node of a parsed bookmark tree.
sealed class ImportBookmarkNode {
/// Creation time recorded in the imported file, or null if it had none.
final DateTime? dateAdded;
/// Modification time recorded in the imported file, or null if it had none.
final DateTime? lastModified;
const ImportBookmarkNode({this.dateAdded, this.lastModified});
}
/// A folder and everything nested underneath it.
final class ImportBookmarkFolder extends ImportBookmarkNode {
final String title;
/// Children in the order they should appear under this folder.
final List<ImportBookmarkNode> children;
const ImportBookmarkFolder({
required this.title,
required this.children,
super.dateAdded,
super.lastModified,
});
}
/// A bookmark pointing at [url].
final class ImportBookmarkItem extends ImportBookmarkNode {
final Uri url;
final String title;
const ImportBookmarkItem({
required this.url,
required this.title,
super.dateAdded,
super.lastModified,
});
}
/// A visual divider between sibling nodes.
final class ImportBookmarkSeparator extends ImportBookmarkNode {
const ImportBookmarkSeparator({super.dateAdded, super.lastModified});
}
/// What a parser found in a bookmark file, and where it belongs.
///
/// [sections] maps the guid of a Places root onto the nodes that should be
/// appended underneath it. Files without root markers produce a single section;
/// Firefox exports that identify their menu/toolbar/unfiled roots produce one
/// section per recognised root.
class ImportBookmarkTree {
final Map<String, List<ImportBookmarkNode>> sections;
/// Counters describing what the parser saw, including what it discarded.
final ImportBookmarkStats stats;
const ImportBookmarkTree({required this.sections, required this.stats});
static const empty = ImportBookmarkTree(
sections: {},
stats: ImportBookmarkStats(),
);
bool get isEmpty => sections.values.every((nodes) => nodes.isEmpty);
}
/// Summary of a parse, useful for reporting and for regression tests that care
/// about what was skipped rather than only about what survived.
class ImportBookmarkStats {
/// Bookmark items that parsed successfully.
final int bookmarkCount;
/// Folders that parsed successfully.
final int folderCount;
/// Separators that parsed successfully.
final int separatorCount;
/// Entries dropped because they had no URL, or one that could not be parsed
/// or carried no scheme.
final int skippedUrlCount;
const ImportBookmarkStats({
this.bookmarkCount = 0,
this.folderCount = 0,
this.separatorCount = 0,
this.skippedUrlCount = 0,
});
ImportBookmarkStats copyWith({
int? bookmarkCount,
int? folderCount,
int? separatorCount,
int? skippedUrlCount,
}) {
return ImportBookmarkStats(
bookmarkCount: bookmarkCount ?? this.bookmarkCount,
folderCount: folderCount ?? this.folderCount,
separatorCount: separatorCount ?? this.separatorCount,
skippedUrlCount: skippedUrlCount ?? this.skippedUrlCount,
);
}
}
@@ -17,125 +17,38 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
part 'bookmarks.g.dart';
/// Check if a root folder is effectively empty (has no non-root children)
bool _isEmptyRootFolder(BookmarkFolder folder) {
if (folder.children == null) return true;
// A root folder is empty if it has no children, or only contains other root folders
return folder.children!.every(
(child) => bookmarkRootIds.contains(child.guid),
);
}
T? _selectChildRecursive<T extends BookmarkItem>(
List<BookmarkItem> children,
String guid,
) {
for (final child in children) {
if (child.guid == guid && child is T) {
return child;
}
if (child case final BookmarkFolder folder) {
if (folder.children != null) {
final result = _selectChildRecursive<T>(folder.children!, guid);
if (result != null) {
return result;
}
}
}
}
return null;
}
T _cloneAndFilterChildrenType<T extends BookmarkItem>(T node) {
if (node is BookmarkFolder) {
if (node.children != null) {
return node.copyWith.children(
node.children
?.whereType<T>()
.map((e) => _cloneAndFilterChildrenType<T>(e))
.toList(),
)
as T;
}
}
return node.clone() as T;
}
BookmarkItem? _cloneAndFilterOnGuids(BookmarkItem node, Set<String> guids) {
if (node is BookmarkFolder) {
if (node.children != null) {
final filtered = node.children
?.where((e) => e is BookmarkFolder || guids.contains(e.guid))
.map((e) => _cloneAndFilterOnGuids(e, guids))
.nonNulls
.toList();
if (filtered.isNotEmpty) {
return node.copyWith.children(filtered);
} else {
return null;
}
}
}
if (guids.contains(node.guid)) {
return node.clone();
}
return null;
}
@Riverpod()
class BookmarksSearch extends _$BookmarksSearch {
final _service = GeckoBookmarksService();
late StreamController<Set<String>> _streamController;
Future<void> search(String query, {int limit = 10}) async {
if (query.isNotEmpty) {
try {
await _service.searchBookmarks(query, limit: limit).then((value) {
if (!_streamController.isClosed) {
_streamController.add(value.map((e) => e.guid).toSet());
}
});
} on PlatformException catch (e) {
if (e.code == 'OperationInterrupted') return;
rethrow;
}
}
}
@override
Stream<Set<String>> build() {
_streamController = StreamController();
ref.onDispose(() async {
await _streamController.close();
});
return _streamController.stream;
}
/// Whether a root folder holds anything worth showing.
///
/// Roots always contain each other, so a root that only contains other roots
/// counts as empty.
bool _hasVisibleContent(BookmarkFolder? folder) {
final children = folder?.children;
if (children == null) return false;
return children.any((child) => !bookmarkRootIds.contains(child.guid));
}
@Riverpod()
class BookmarkSearchResults extends _$BookmarkSearchResults {
final _service = GeckoBookmarksService();
/// Identifies the most recent request.
///
/// Typing starts a search per keystroke and storage does not answer them in
/// order, so a slow early query could otherwise land after a fast later one
/// and leave the list showing results for text the user has moved on from.
int _latestRequest = 0;
Future<void> search(String query, {int limit = 10}) async {
final request = ++_latestRequest;
if (query.isEmpty) {
state = [];
return;
@@ -143,7 +56,7 @@ class BookmarkSearchResults extends _$BookmarkSearchResults {
try {
final results = await _service.searchBookmarks(query, limit: limit);
if (!ref.mounted) return;
if (!ref.mounted || request != _latestRequest) return;
state = results
.map(BookmarkItem.parseRecursive)
.whereType<BookmarkEntry>()
@@ -160,98 +73,75 @@ class BookmarkSearchResults extends _$BookmarkSearchResults {
}
}
/// A single folder with its direct children.
///
/// The load is scoped to one folder, so its cost tracks the folder being shown
/// rather than the size of the library. Rebuilds whenever the repository
/// reports a change.
@Riverpod()
AsyncValue<T?> bookmarks<T extends BookmarkItem>(
Future<BookmarkFolder?> bookmarkFolder(Ref ref, String guid) {
ref.watch(bookmarksRepositoryProvider);
return ref.read(bookmarksRepositoryProvider.notifier).getFolder(guid);
}
/// The folder shown by the bookmark list, with the roots the user asked to
/// hide already removed.
///
/// Emptiness can only be judged by looking inside each root, but the root level
/// has a fixed handful of children, so the extra loads are bounded and shallow.
@Riverpod()
Future<BookmarkFolder?> bookmarkListFolder(
Ref ref,
String entryGuid, {
bool hideEmptyRoots = false,
}) {
final bookmarksAsync = ref.watch(bookmarksRepositoryProvider);
}) async {
final folder = await ref.watch(bookmarkFolderProvider(entryGuid).future);
return bookmarksAsync.whenData((bookmarkNode) {
T? selectedNode;
if (folder == null ||
!hideEmptyRoots ||
entryGuid != BookmarkRoot.root.id ||
folder.children == null) {
return folder;
}
if (bookmarkNode != null && bookmarkNode is T) {
if (bookmarkNode.guid == entryGuid) {
selectedNode = bookmarkNode;
} else if (bookmarkNode case final BookmarkFolder folder) {
if (folder.children != null) {
selectedNode = _selectChildRecursive<T>(folder.children!, entryGuid);
}
}
final visible = <BookmarkItem>[];
for (final child in folder.children!) {
if (child is! BookmarkFolder || child.guid == BookmarkRoot.mobile.id) {
visible.add(child);
continue;
}
if (selectedNode != null) {
var result = _cloneAndFilterChildrenType<T>(selectedNode);
// Filter empty root folders when viewing root level (excluding WebLibre root)
if (hideEmptyRoots &&
entryGuid == BookmarkRoot.root.id &&
result is BookmarkFolder) {
final filteredChildren = result.children
?.where(
(child) =>
child is! BookmarkFolder ||
child.guid == BookmarkRoot.mobile.id ||
!_isEmptyRootFolder(child),
)
.toList();
result = result.copyWith.children(filteredChildren) as T;
}
return result;
final loaded = await ref.watch(bookmarkFolderProvider(child.guid).future);
if (_hasVisibleContent(loaded)) {
visible.add(child);
}
}
return null;
});
return folder.copyWith.children(visible);
}
/// Guids of the bookmarks pointing at [url], or an empty list when there are
/// none.
///
/// Backed by a storage lookup, so "is this page bookmarked?" costs the same
/// whether the user has ten bookmarks or fifty thousand.
@Riverpod()
class SeamlessBookmarks extends _$SeamlessBookmarks {
bool _hasSearch = false;
Future<List<String>> bookmarkGuidsForUrl(Ref ref, Uri? url) async {
if (url == null) return const [];
void search(String input) {
if (input.isNotEmpty) {
if (!_hasSearch) {
_hasSearch = true;
ref.invalidateSelf();
}
//Don't block
unawaited(ref.read(bookmarksSearchProvider.notifier).search(input));
} else if (_hasSearch) {
_hasSearch = false;
ref.invalidateSelf();
}
}
@override
AsyncValue<BookmarkItem?> build(
String entryGuid, {
bool hideEmptyRoots = false,
}) {
final bookmarks = ref.watch(
bookmarksProvider<BookmarkItem>(
entryGuid,
hideEmptyRoots: hideEmptyRoots,
),
);
if (_hasSearch) {
final filterGuids = ref.watch(bookmarksSearchProvider);
return bookmarks.map(
data: (node) =>
node.value.mapNotNull(
(node) => filterGuids.whenData(
(results) => _cloneAndFilterOnGuids(node, results),
),
) ??
const AsyncValue.data(null),
error: (e) => e,
loading: (s) => s,
);
} else {
return bookmarks;
}
}
ref.watch(bookmarksRepositoryProvider);
return ref
.read(bookmarksRepositoryProvider.notifier)
.bookmarkGuidsForUrl(url);
}
/// Number of bookmarks inside the trees rooted at [guids].
///
/// Used to tell the user how much a destructive action will affect.
@Riverpod()
Future<int> bookmarkCountInTrees(Ref ref, List<String> guids) {
ref.watch(bookmarksRepositoryProvider);
return ref
.read(bookmarksRepositoryProvider.notifier)
.countBookmarksInTrees(guids);
}
@@ -9,50 +9,6 @@ part of 'bookmarks.dart';
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BookmarksSearch)
final bookmarksSearchProvider = BookmarksSearchProvider._();
final class BookmarksSearchProvider
extends $StreamNotifierProvider<BookmarksSearch, Set<String>> {
BookmarksSearchProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bookmarksSearchProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bookmarksSearchHash();
@$internal
@override
BookmarksSearch create() => BookmarksSearch();
}
String _$bookmarksSearchHash() => r'41053cbc1014e0fdd04d9510bf15c9896a9f752d';
abstract class _$BookmarksSearch extends $StreamNotifier<Set<String>> {
Stream<Set<String>> build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<AsyncValue<Set<String>>, Set<String>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<Set<String>>, Set<String>>,
AsyncValue<Set<String>>,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(BookmarkSearchResults)
final bookmarkSearchResultsProvider = BookmarkSearchResultsProvider._();
@@ -106,159 +62,70 @@ abstract class _$BookmarkSearchResults extends $Notifier<List<BookmarkEntry>> {
}
}
@ProviderFor(bookmarks)
final bookmarksProvider = BookmarksFamily._();
/// A single folder with its direct children.
///
/// The load is scoped to one folder, so its cost tracks the folder being shown
/// rather than the size of the library. Rebuilds whenever the repository
/// reports a change.
final class BookmarksProvider<T extends BookmarkItem>
extends $FunctionalProvider<AsyncValue<T?>, AsyncValue<T?>, AsyncValue<T?>>
with $Provider<AsyncValue<T?>> {
BookmarksProvider._({
required BookmarksFamily super.from,
required (String, {bool hideEmptyRoots}) super.argument,
@ProviderFor(bookmarkFolder)
final bookmarkFolderProvider = BookmarkFolderFamily._();
/// A single folder with its direct children.
///
/// The load is scoped to one folder, so its cost tracks the folder being shown
/// rather than the size of the library. Rebuilds whenever the repository
/// reports a change.
final class BookmarkFolderProvider
extends
$FunctionalProvider<
AsyncValue<BookmarkFolder?>,
BookmarkFolder?,
FutureOr<BookmarkFolder?>
>
with $FutureModifier<BookmarkFolder?>, $FutureProvider<BookmarkFolder?> {
/// A single folder with its direct children.
///
/// The load is scoped to one folder, so its cost tracks the folder being shown
/// rather than the size of the library. Rebuilds whenever the repository
/// reports a change.
BookmarkFolderProvider._({
required BookmarkFolderFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'bookmarksProvider',
name: r'bookmarkFolderProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bookmarksHash();
String debugGetCreateSourceHash() => _$bookmarkFolderHash();
@override
String toString() {
return r'bookmarksProvider'
'<${T}>'
'$argument';
}
@$internal
@override
$ProviderElement<AsyncValue<T?>> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
AsyncValue<T?> create(Ref ref) {
final argument = this.argument as (String, {bool hideEmptyRoots});
return bookmarks<T>(
ref,
argument.$1,
hideEmptyRoots: argument.hideEmptyRoots,
);
}
$R _captureGenerics<$R>($R Function<T extends BookmarkItem>() cb) {
return cb<T>();
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<T?> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<T?>>(value),
);
}
@override
bool operator ==(Object other) {
return other is BookmarksProvider &&
other.runtimeType == runtimeType &&
other.argument == argument;
}
@override
int get hashCode {
return Object.hash(runtimeType, argument);
}
}
String _$bookmarksHash() => r'72b54c4ff18cfdb60824a57607628be6b61d25d4';
final class BookmarksFamily extends $Family {
BookmarksFamily._()
: super(
retry: null,
name: r'bookmarksProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
BookmarksProvider<T> call<T extends BookmarkItem>(
String entryGuid, {
bool hideEmptyRoots = false,
}) => BookmarksProvider<T>._(
argument: (entryGuid, hideEmptyRoots: hideEmptyRoots),
from: this,
);
@override
String toString() => r'bookmarksProvider';
/// {@macro riverpod.override_with}
Override overrideWith(
AsyncValue<T?> Function<T extends BookmarkItem>(
Ref ref,
(String, {bool hideEmptyRoots}) args,
)
create,
) => $FamilyOverride(
from: this,
createElement: (pointer) {
final provider = pointer.origin as BookmarksProvider;
return provider._captureGenerics(<T extends BookmarkItem>() {
provider as BookmarksProvider<T>;
final argument = provider.argument as (String, {bool hideEmptyRoots});
return provider
.$view(create: (ref) => create(ref, argument))
.$createElement(pointer);
});
},
);
}
@ProviderFor(SeamlessBookmarks)
final seamlessBookmarksProvider = SeamlessBookmarksFamily._();
final class SeamlessBookmarksProvider
extends $NotifierProvider<SeamlessBookmarks, AsyncValue<BookmarkItem?>> {
SeamlessBookmarksProvider._({
required SeamlessBookmarksFamily super.from,
required (String, {bool hideEmptyRoots}) super.argument,
}) : super(
retry: null,
name: r'seamlessBookmarksProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$seamlessBookmarksHash();
@override
String toString() {
return r'seamlessBookmarksProvider'
return r'bookmarkFolderProvider'
''
'$argument';
'($argument)';
}
@$internal
@override
SeamlessBookmarks create() => SeamlessBookmarks();
$FutureProviderElement<BookmarkFolder?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<BookmarkItem?> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<BookmarkItem?>>(value),
);
@override
FutureOr<BookmarkFolder?> create(Ref ref) {
final argument = this.argument as String;
return bookmarkFolder(ref, argument);
}
@override
bool operator ==(Object other) {
return other is SeamlessBookmarksProvider && other.argument == argument;
return other is BookmarkFolderProvider && other.argument == argument;
}
@override
@@ -267,64 +134,346 @@ final class SeamlessBookmarksProvider
}
}
String _$seamlessBookmarksHash() => r'240b213fa8fe595781ccc608c5d551be54c31992';
String _$bookmarkFolderHash() => r'a0a3754a2b8099415cffad6358d9388dc7c4e7bf';
final class SeamlessBookmarksFamily extends $Family
with
$ClassFamilyOverride<
SeamlessBookmarks,
AsyncValue<BookmarkItem?>,
AsyncValue<BookmarkItem?>,
AsyncValue<BookmarkItem?>,
(String, {bool hideEmptyRoots})
> {
SeamlessBookmarksFamily._()
/// A single folder with its direct children.
///
/// The load is scoped to one folder, so its cost tracks the folder being shown
/// rather than the size of the library. Rebuilds whenever the repository
/// reports a change.
final class BookmarkFolderFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<BookmarkFolder?>, String> {
BookmarkFolderFamily._()
: super(
retry: null,
name: r'seamlessBookmarksProvider',
name: r'bookmarkFolderProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
SeamlessBookmarksProvider call(
/// A single folder with its direct children.
///
/// The load is scoped to one folder, so its cost tracks the folder being shown
/// rather than the size of the library. Rebuilds whenever the repository
/// reports a change.
BookmarkFolderProvider call(String guid) =>
BookmarkFolderProvider._(argument: guid, from: this);
@override
String toString() => r'bookmarkFolderProvider';
}
/// The folder shown by the bookmark list, with the roots the user asked to
/// hide already removed.
///
/// Emptiness can only be judged by looking inside each root, but the root level
/// has a fixed handful of children, so the extra loads are bounded and shallow.
@ProviderFor(bookmarkListFolder)
final bookmarkListFolderProvider = BookmarkListFolderFamily._();
/// The folder shown by the bookmark list, with the roots the user asked to
/// hide already removed.
///
/// Emptiness can only be judged by looking inside each root, but the root level
/// has a fixed handful of children, so the extra loads are bounded and shallow.
final class BookmarkListFolderProvider
extends
$FunctionalProvider<
AsyncValue<BookmarkFolder?>,
BookmarkFolder?,
FutureOr<BookmarkFolder?>
>
with $FutureModifier<BookmarkFolder?>, $FutureProvider<BookmarkFolder?> {
/// The folder shown by the bookmark list, with the roots the user asked to
/// hide already removed.
///
/// Emptiness can only be judged by looking inside each root, but the root level
/// has a fixed handful of children, so the extra loads are bounded and shallow.
BookmarkListFolderProvider._({
required BookmarkListFolderFamily super.from,
required (String, {bool hideEmptyRoots}) super.argument,
}) : super(
retry: null,
name: r'bookmarkListFolderProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bookmarkListFolderHash();
@override
String toString() {
return r'bookmarkListFolderProvider'
''
'$argument';
}
@$internal
@override
$FutureProviderElement<BookmarkFolder?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<BookmarkFolder?> create(Ref ref) {
final argument = this.argument as (String, {bool hideEmptyRoots});
return bookmarkListFolder(
ref,
argument.$1,
hideEmptyRoots: argument.hideEmptyRoots,
);
}
@override
bool operator ==(Object other) {
return other is BookmarkListFolderProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$bookmarkListFolderHash() =>
r'da6b257ef55b38021a2da7e41209b66a58cfbbb9';
/// The folder shown by the bookmark list, with the roots the user asked to
/// hide already removed.
///
/// Emptiness can only be judged by looking inside each root, but the root level
/// has a fixed handful of children, so the extra loads are bounded and shallow.
final class BookmarkListFolderFamily extends $Family
with
$FunctionalFamilyOverride<
FutureOr<BookmarkFolder?>,
(String, {bool hideEmptyRoots})
> {
BookmarkListFolderFamily._()
: super(
retry: null,
name: r'bookmarkListFolderProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// The folder shown by the bookmark list, with the roots the user asked to
/// hide already removed.
///
/// Emptiness can only be judged by looking inside each root, but the root level
/// has a fixed handful of children, so the extra loads are bounded and shallow.
BookmarkListFolderProvider call(
String entryGuid, {
bool hideEmptyRoots = false,
}) => SeamlessBookmarksProvider._(
}) => BookmarkListFolderProvider._(
argument: (entryGuid, hideEmptyRoots: hideEmptyRoots),
from: this,
);
@override
String toString() => r'seamlessBookmarksProvider';
String toString() => r'bookmarkListFolderProvider';
}
abstract class _$SeamlessBookmarks
extends $Notifier<AsyncValue<BookmarkItem?>> {
late final _$args = ref.$arg as (String, {bool hideEmptyRoots});
String get entryGuid => _$args.$1;
bool get hideEmptyRoots => _$args.hideEmptyRoots;
/// Guids of the bookmarks pointing at [url], or an empty list when there are
/// none.
///
/// Backed by a storage lookup, so "is this page bookmarked?" costs the same
/// whether the user has ten bookmarks or fifty thousand.
@ProviderFor(bookmarkGuidsForUrl)
final bookmarkGuidsForUrlProvider = BookmarkGuidsForUrlFamily._();
/// Guids of the bookmarks pointing at [url], or an empty list when there are
/// none.
///
/// Backed by a storage lookup, so "is this page bookmarked?" costs the same
/// whether the user has ten bookmarks or fifty thousand.
final class BookmarkGuidsForUrlProvider
extends
$FunctionalProvider<
AsyncValue<List<String>>,
List<String>,
FutureOr<List<String>>
>
with $FutureModifier<List<String>>, $FutureProvider<List<String>> {
/// Guids of the bookmarks pointing at [url], or an empty list when there are
/// none.
///
/// Backed by a storage lookup, so "is this page bookmarked?" costs the same
/// whether the user has ten bookmarks or fifty thousand.
BookmarkGuidsForUrlProvider._({
required BookmarkGuidsForUrlFamily super.from,
required Uri? super.argument,
}) : super(
retry: null,
name: r'bookmarkGuidsForUrlProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
AsyncValue<BookmarkItem?> build(
String entryGuid, {
bool hideEmptyRoots = false,
});
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref =
this.ref as $Ref<AsyncValue<BookmarkItem?>, AsyncValue<BookmarkItem?>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<BookmarkItem?>, AsyncValue<BookmarkItem?>>,
AsyncValue<BookmarkItem?>,
Object?,
Object?
>;
return element.handleCreate(
ref,
() => build(_$args.$1, hideEmptyRoots: _$args.hideEmptyRoots),
);
String debugGetCreateSourceHash() => _$bookmarkGuidsForUrlHash();
@override
String toString() {
return r'bookmarkGuidsForUrlProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<List<String>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<String>> create(Ref ref) {
final argument = this.argument as Uri?;
return bookmarkGuidsForUrl(ref, argument);
}
@override
bool operator ==(Object other) {
return other is BookmarkGuidsForUrlProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$bookmarkGuidsForUrlHash() =>
r'aeca791afdcad74c2e8af93c392860ea3cb92b20';
/// Guids of the bookmarks pointing at [url], or an empty list when there are
/// none.
///
/// Backed by a storage lookup, so "is this page bookmarked?" costs the same
/// whether the user has ten bookmarks or fifty thousand.
final class BookmarkGuidsForUrlFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<List<String>>, Uri?> {
BookmarkGuidsForUrlFamily._()
: super(
retry: null,
name: r'bookmarkGuidsForUrlProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Guids of the bookmarks pointing at [url], or an empty list when there are
/// none.
///
/// Backed by a storage lookup, so "is this page bookmarked?" costs the same
/// whether the user has ten bookmarks or fifty thousand.
BookmarkGuidsForUrlProvider call(Uri? url) =>
BookmarkGuidsForUrlProvider._(argument: url, from: this);
@override
String toString() => r'bookmarkGuidsForUrlProvider';
}
/// Number of bookmarks inside the trees rooted at [guids].
///
/// Used to tell the user how much a destructive action will affect.
@ProviderFor(bookmarkCountInTrees)
final bookmarkCountInTreesProvider = BookmarkCountInTreesFamily._();
/// Number of bookmarks inside the trees rooted at [guids].
///
/// Used to tell the user how much a destructive action will affect.
final class BookmarkCountInTreesProvider
extends $FunctionalProvider<AsyncValue<int>, int, FutureOr<int>>
with $FutureModifier<int>, $FutureProvider<int> {
/// Number of bookmarks inside the trees rooted at [guids].
///
/// Used to tell the user how much a destructive action will affect.
BookmarkCountInTreesProvider._({
required BookmarkCountInTreesFamily super.from,
required List<String> super.argument,
}) : super(
retry: null,
name: r'bookmarkCountInTreesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bookmarkCountInTreesHash();
@override
String toString() {
return r'bookmarkCountInTreesProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<int> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<int> create(Ref ref) {
final argument = this.argument as List<String>;
return bookmarkCountInTrees(ref, argument);
}
@override
bool operator ==(Object other) {
return other is BookmarkCountInTreesProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$bookmarkCountInTreesHash() =>
r'efe0cc45e7e77aa542d9ebbd9f4fee5cf9ee5903';
/// Number of bookmarks inside the trees rooted at [guids].
///
/// Used to tell the user how much a destructive action will affect.
final class BookmarkCountInTreesFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<int>, List<String>> {
BookmarkCountInTreesFamily._()
: super(
retry: null,
name: r'bookmarkCountInTreesProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Number of bookmarks inside the trees rooted at [guids].
///
/// Used to tell the user how much a destructive action will affect.
BookmarkCountInTreesProvider call(List<String> guids) =>
BookmarkCountInTreesProvider._(argument: guids, from: this);
@override
String toString() => r'bookmarkCountInTreesProvider';
}
@@ -18,11 +18,12 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_import_isolate.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_importer.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart';
part 'bookmarks.g.dart';
@@ -40,7 +41,7 @@ class BookmarksRepository extends _$BookmarksRepository {
int? position,
}) async {
await _service.addItem(parentGuid, url, title, position);
ref.invalidateSelf();
_notifyChanged();
}
Future<void> addFolder({
@@ -49,7 +50,7 @@ class BookmarksRepository extends _$BookmarksRepository {
int? position,
}) async {
await _service.addFolder(parentGuid, title, position);
ref.invalidateSelf();
_notifyChanged();
}
Future<void> editBookmark({
@@ -68,7 +69,7 @@ class BookmarksRepository extends _$BookmarksRepository {
position: position,
),
);
ref.invalidateSelf();
_notifyChanged();
}
Future<void> editFolder({
@@ -81,12 +82,12 @@ class BookmarksRepository extends _$BookmarksRepository {
guid,
BookmarkInfo(title: title, parentGuid: parentGuid, position: position),
);
ref.invalidateSelf();
_notifyChanged();
}
Future<void> delete(String guid) async {
await _service.deleteNode(guid);
ref.invalidateSelf();
_notifyChanged();
}
Future<void> moveMany({
@@ -104,7 +105,7 @@ class BookmarksRepository extends _$BookmarksRepository {
BookmarkInfo(parentGuid: targetParentGuid),
);
}
ref.invalidateSelf();
_notifyChanged();
}
Future<void> deleteMany(Iterable<String> guids) async {
@@ -115,7 +116,7 @@ class BookmarksRepository extends _$BookmarksRepository {
}
await _service.deleteNode(guid);
}
ref.invalidateSelf();
_notifyChanged();
}
Future<void> flattenFolder({required BookmarkFolder folder}) async {
@@ -137,7 +138,45 @@ class BookmarksRepository extends _$BookmarksRepository {
}
}
await _service.deleteNode(folder.guid);
ref.invalidateSelf();
_notifyChanged();
}
/// Loads a single folder and its direct children.
///
/// This is the load the bookmark UI is built on: the cost is proportional to
/// the folder being shown, not to the size of the library. Reach for
/// [getFolderTree] only when an operation genuinely needs descendants.
Future<BookmarkFolder?> getFolder(String guid) async {
final node = await _service.getTree(guid);
if (node == null || node.type != BookmarkNodeType.folder) return null;
return BookmarkItem.parseRecursive(node) as BookmarkFolder?;
}
/// Loads a folder with every descendant.
///
/// Only for operations that need the whole subtree — export, flatten, "open
/// all in folder". Never for rendering a list.
Future<BookmarkFolder?> getFolderTree(String guid) async {
final node = await _service.getTree(guid, recursive: true);
if (node == null || node.type != BookmarkNodeType.folder) return null;
return BookmarkItem.parseRecursive(node) as BookmarkFolder?;
}
/// Number of bookmark items inside the trees rooted at [guids].
///
/// Counted in storage, so this stays cheap on a large library.
Future<int> countBookmarksInTrees(Iterable<String> guids) {
if (guids.isEmpty) return Future.value(0);
return _service.countBookmarksInTrees(guids.toList());
}
/// Guids of every bookmark entry pointing at [url].
///
/// Answers "is this page bookmarked?" through a storage lookup instead of
/// scanning an in-memory tree.
Future<List<String>> bookmarkGuidsForUrl(Uri url) async {
final nodes = await _service.getBookmarksWithUrl(url);
return nodes.map((node) => node.guid).toList();
}
/// Returns the GUIDs of all descendant folders of [guid] by fetching the
@@ -163,18 +202,44 @@ class BookmarksRepository extends _$BookmarksRepository {
Future<void> eraseEverything(BookmarkRoot root) async {
await _service.eraseEverything(root);
ref.invalidateSelf();
_notifyChanged();
}
Future<int> importFromJSON(String jsonString, {bool replace = false}) async {
final count = await _jsonUtils.importFromJSON(jsonString, replace: replace);
ref.invalidateSelf();
_notifyChanged();
return count;
}
Future<int> importFromHTML(String htmlString, {bool replace = false}) async {
final count = await _htmlUtils.importFromHTML(htmlString, replace: replace);
ref.invalidateSelf();
_notifyChanged();
return count;
}
/// Imports the bookmark file at [path], parsing it in a background isolate.
///
/// Preferred over [importFromHTML]/[importFromJSON] for user-initiated
/// imports: neither the raw file nor the intermediate parse tree ever touches
/// the UI isolate.
Future<int> importFromFile({
required String path,
required BookmarkImportFormat format,
bool replace = false,
}) async {
final tree = await parseBookmarkFile(
path: path,
format: format,
// Replacing the library is the only case where a file's own root folders
// should take over the corresponding Places roots.
preserveRootFolders: replace,
);
final count = await BookmarkTreeImporter(
_service,
).import(tree, replace: replace);
_notifyChanged();
return count;
}
@@ -188,9 +253,16 @@ class BookmarksRepository extends _$BookmarksRepository {
return await _htmlUtils.exportToHTML(root: root);
}
/// A revision that advances whenever bookmarks change.
///
/// The repository deliberately holds no bookmark data. It used to build the
/// entire tree recursively from [BookmarkRoot.root], which meant every
/// consumer paid for the whole library — the reason opening bookmarks after
/// a large import or sync could bring the app down. Data now comes from the
/// folder-scoped providers, which watch this value to know when to reload.
@override
Future<BookmarkItem?> build() async {
final node = await _service.getTree(BookmarkRoot.root.id, recursive: true);
return node.mapNotNull(BookmarkItem.parseRecursive);
}
int build() => 0;
/// Signals that stored bookmarks changed, prompting dependents to reload.
void _notifyChanged() => state++;
}
@@ -13,7 +13,7 @@ part of 'bookmarks.dart';
final bookmarksRepositoryProvider = BookmarksRepositoryProvider._();
final class BookmarksRepositoryProvider
extends $AsyncNotifierProvider<BookmarksRepository, BookmarkItem?> {
extends $NotifierProvider<BookmarksRepository, int> {
BookmarksRepositoryProvider._()
: super(
from: null,
@@ -31,22 +31,30 @@ final class BookmarksRepositoryProvider
@$internal
@override
BookmarksRepository create() => BookmarksRepository();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
}
String _$bookmarksRepositoryHash() =>
r'2169d5b354c4a22192096451c96ab1490cf55ab4';
r'bf95d30f21773e931b12fd85d03d072d88d5b715';
abstract class _$BookmarksRepository extends $AsyncNotifier<BookmarkItem?> {
FutureOr<BookmarkItem?> build();
abstract class _$BookmarksRepository extends $Notifier<int> {
int build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<AsyncValue<BookmarkItem?>, BookmarkItem?>;
final ref = this.ref as $Ref<int, int>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<BookmarkItem?>, BookmarkItem?>,
AsyncValue<BookmarkItem?>,
AnyNotifier<int, int>,
int,
Object?,
Object?
>;
@@ -21,142 +21,101 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
/// Recursively sorts a bookmark tree by the given sort type.
/// Root-level built-in folders are kept in their canonical order.
BookmarkItem sortBookmarkTree(
BookmarkItem item,
/// Sorts one folder's direct children by the given sort type.
///
/// Only the level being displayed is sorted; descendants are not loaded, so
/// there is nothing deeper to order. Root-level built-in folders are kept
/// pinned in their canonical order ahead of everything else.
List<BookmarkItem> sortBookmarkChildren(
List<BookmarkItem> children,
BookmarkSortType sortType, {
bool isRoot = false,
}) {
if (sortType == BookmarkSortType.manual) return item;
if (sortType == BookmarkSortType.manual) return children;
if (item is BookmarkFolder && item.children != null) {
final sortedChildren = item.children!.map((child) {
return sortBookmarkTree(child, sortType);
}).toList();
// At root level, keep built-in root folders pinned in original order
if (isRoot) {
final rootFolders = <BookmarkItem>[];
final nonRootItems = <BookmarkItem>[];
for (final child in sortedChildren) {
if (bookmarkRootIds.contains(child.guid)) {
rootFolders.add(child);
} else {
nonRootItems.add(child);
}
}
nonRootItems.sort((a, b) => compareBookmarkItems(a, b, sortType));
return BookmarkFolder(
guid: item.guid,
parentGuid: item.parentGuid,
title: item.title,
position: item.position,
dateAdded: item.dateAdded,
children: [...rootFolders, ...nonRootItems],
);
}
sortedChildren.sort((a, b) => compareBookmarkItems(a, b, sortType));
return BookmarkFolder(
guid: item.guid,
parentGuid: item.parentGuid,
title: item.title,
position: item.position,
dateAdded: item.dateAdded,
children: sortedChildren,
);
if (!isRoot) {
return [...children]..sort((a, b) => compareBookmarkItems(a, b, sortType));
}
return item;
}
/// Collects all descendant folder GUIDs from a folder (not including the folder itself).
Set<String> collectDescendantFolderGuids(BookmarkFolder folder) {
final result = <String>{};
if (folder.children != null) {
for (final child in folder.children!) {
if (child is BookmarkFolder) {
result.add(child.guid);
result.addAll(collectDescendantFolderGuids(child));
}
final rootFolders = <BookmarkItem>[];
final rest = <BookmarkItem>[];
for (final child in children) {
if (bookmarkRootIds.contains(child.guid)) {
rootFolders.add(child);
} else {
rest.add(child);
}
}
return result;
rest.sort((a, b) => compareBookmarkItems(a, b, sortType));
return [...rootFolders, ...rest];
}
/// Resolves BookmarkItems from a tree by their GUIDs.
List<BookmarkItem> resolveSelectedItems(BookmarkItem root, Set<String> guids) {
final result = <BookmarkItem>[];
_collectByGuids(root, guids, result);
return result;
/// One rendered line of the bookmark list: an item and how deep it sits.
///
/// The list flattens the expanded folders into rows rather than nesting
/// widgets, so it can stay a `ListView.builder` and only build what is on
/// screen — a folder with thousands of entries costs the same to expand as a
/// small one.
class BookmarkRow {
final BookmarkItem item;
final int depth;
/// True when this row only stands in for [item]'s children while they load.
///
/// A placeholder repeats the folder it belongs to, so it must never be
/// treated as a second occurrence of that folder — acting on it would apply
/// the same operation twice.
final bool isPlaceholder;
const BookmarkRow(this.item, this.depth, {this.isPlaceholder = false});
}
void _collectByGuids(
BookmarkItem item,
/// Resolves the items of [children] whose GUIDs are in [guids].
///
/// The caller passes whatever is currently displayed, which may be one folder's
/// children, several expanded levels, or search hits from all over the library.
List<BookmarkItem> resolveSelectedItems(
List<BookmarkItem> children,
Set<String> guids,
List<BookmarkItem> result,
) {
if (guids.contains(item.guid)) {
result.add(item);
}
if (item is BookmarkFolder && item.children != null) {
for (final child in item.children!) {
_collectByGuids(child, guids, result);
}
}
return children.where((child) => guids.contains(child.guid)).toList();
}
/// Whether a folder can be flattened (non-root, has a parent, has children).
bool canFlattenFolder(BookmarkFolder folder) {
return folder.parentGuid != null &&
!bookmarkRootIds.contains(folder.guid) &&
folder.children != null &&
folder.children!.isNotEmpty;
}
/// Drops selections that sit inside another selected folder.
///
/// Expanding a folder makes its children selectable alongside it, and acting on
/// both would move a child out of the very folder that just moved, or delete it
/// twice. A folder's descendants are exactly the rows that follow it until the
/// depth returns to its own, so one pass over [rows] is enough.
Set<String> normalizeSelection(List<BookmarkRow> rows, Set<String> selected) {
final result = <String>{};
var skipBelowDepth = -1;
/// Normalizes a selection set: removes items that are descendants of selected folders.
/// This prevents double-applying moves when both a folder and its children are selected.
Set<String> normalizeSelection(BookmarkItem root, Set<String> selectedGuids) {
final items = resolveSelectedItems(root, selectedGuids);
final folderGuidsToRemove = <String>{};
for (final item in items) {
if (item is BookmarkFolder) {
_collectAllDescendantGuids(item, folderGuidsToRemove);
for (final row in rows) {
if (skipBelowDepth >= 0) {
if (row.depth > skipBelowDepth) continue;
skipBelowDepth = -1;
}
}
return selectedGuids.difference(folderGuidsToRemove);
}
if (row.isPlaceholder) continue;
void _collectAllDescendantGuids(BookmarkFolder folder, Set<String> result) {
if (folder.children != null) {
for (final child in folder.children!) {
result.add(child.guid);
if (child is BookmarkFolder) {
_collectAllDescendantGuids(child, result);
}
}
}
}
/// Returns GUIDs of all bookmark entries matching [url] in the tree.
List<String> bookmarkGuidsForUrl(BookmarkItem? root, Uri? url) {
final result = <String>[];
if (root == null || url == null) return result;
void collect(BookmarkItem item) {
if (item is BookmarkEntry && item.url == url) {
result.add(item.guid);
}
if (item is BookmarkFolder) {
for (final child in item.children ?? const <BookmarkItem>[]) {
collect(child);
if (selected.contains(row.item.guid)) {
result.add(row.item.guid);
if (row.item is BookmarkFolder) {
skipBelowDepth = row.depth;
}
}
}
collect(root);
return result;
}
/// Whether a folder can be flattened (non-root and has a parent).
///
/// Whether it actually holds anything is left to the repository, which reads
/// the folder's children at the moment of the operation rather than relying on
/// what the list happens to have loaded.
bool canFlattenFolder(BookmarkFolder folder) {
return folder.parentGuid != null && !bookmarkRootIds.contains(folder.guid);
}
@@ -19,16 +19,33 @@
*/
import 'package:flutter/material.dart';
Future<bool?> showDeleteFolderDialog(BuildContext context) {
/// Confirms deleting a folder and everything inside it.
///
/// Pass [bookmarkCount] to name how many bookmarks go with it. The list only
/// shows one level at a time, so the contents of a folder are usually off
/// screen when this is asked.
Future<bool?> showDeleteFolderDialog(
BuildContext context, {
int? bookmarkCount,
}) {
return showDialog<bool?>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
icon: const Icon(Icons.warning),
title: const Text('Delete Folder'),
content: const Text(
'Are you sure you want to delete this Folder including all bookmarks?',
),
content: Text(switch (bookmarkCount) {
null =>
'Are you sure you want to delete this Folder including '
'all bookmarks?',
0 => 'Are you sure you want to delete this Folder?',
1 =>
'Are you sure you want to delete this Folder and the '
'1 bookmark inside it?',
final count =>
'Are you sure you want to delete this Folder and the '
'$count bookmarks inside it?',
}),
actions: <Widget>[
TextButton(
onPressed: () {
@@ -17,10 +17,9 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:animated_tree_view/animated_tree_view.dart';
import 'package:convert/convert.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
@@ -44,6 +43,7 @@ import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dial
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_folder_dialog.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/import_bookmarks_dialog.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_import_isolate.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
@@ -52,6 +52,15 @@ import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/ui_helper.dart';
/// How many hits an in-list search asks storage for.
///
/// Generous enough to be useful on a large library while staying a bounded,
/// lazily rendered list.
const _searchResultLimit = 200;
/// Indentation applied per level of folder nesting.
const _indentPerDepth = 20.0;
class BookmarkListScreen extends HookConsumerWidget {
final String entryGuid;
@@ -59,18 +68,15 @@ class BookmarkListScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final treeController =
useState<TreeViewController<BookmarkItem, TreeNode<BookmarkItem>>?>(
null,
);
// Tracks which folder GUIDs are expanded, so expansion state survives
// tree rebuilds caused by sort type changes.
final expandedGuids = useRef(<String>{});
final hideEmptyRoots = useState(true);
final uiState = ref.watch(bookmarkListUiStateProvider);
final uiStateNotifier = ref.read(bookmarkListUiStateProvider.notifier);
final bookmarkList = ref.watch(
seamlessBookmarksProvider(
// Only the folder being shown is loaded. Descendants stay in storage until
// the user navigates into them, which is what keeps this screen openable on
// a large library.
final folderAsync = ref.watch(
bookmarkListFolderProvider(
entryGuid,
hideEmptyRoots: hideEmptyRoots.value,
),
@@ -78,25 +84,46 @@ class BookmarkListScreen extends HookConsumerWidget {
final textFilterEnabled = useState(false);
final textFilterController = useTextEditingController();
final searchQuery = useState('');
useOnListenableChange(textFilterController, () {
if (ref.exists(
seamlessBookmarksProvider(
entryGuid,
hideEmptyRoots: hideEmptyRoots.value,
),
)) {
searchQuery.value = textFilterController.text;
// Searching goes through storage rather than filtering a loaded tree, so
// it reaches bookmarks this screen never loaded.
unawaited(
ref
.read(
seamlessBookmarksProvider(
entryGuid,
hideEmptyRoots: hideEmptyRoots.value,
).notifier,
)
.search(textFilterController.text);
}
.read(bookmarkSearchResultsProvider.notifier)
.search(textFilterController.text, limit: _searchResultLimit),
);
});
final isSearching = searchQuery.value.isNotEmpty;
final searchResults = ref.watch(bookmarkSearchResultsProvider);
// Folders the user has opened. Keyed by guid so expansion survives sort and
// filter changes.
final expandedGuids = useState(<String>{});
// Storage can only match bookmarks, never folders, so "Folders Only" and a
// search query have no overlap to show.
final searchSuppressedByFilter = isSearching && uiState.foldersOnly;
// Everything currently on screen, flattened. Selection is resolved against
// exactly these rows — search hits come from anywhere in the library, and
// expanded folders contribute items from below the entry folder.
final rows = isSearching
? [
if (!searchSuppressedByFilter)
for (final result in searchResults) BookmarkRow(result, 0),
]
: _buildRows(
ref,
folderAsync.value,
uiState,
expandedGuids.value,
depth: 0,
);
return PopScope(
canPop: !uiState.selectionMode,
onPopInvokedWithResult: (didPop, _) {
@@ -106,13 +133,18 @@ class BookmarkListScreen extends HookConsumerWidget {
},
child: Scaffold(
appBar: uiState.selectionMode
? _buildSelectionAppBar(context, ref, uiState, uiStateNotifier)
? _buildSelectionAppBar(
context,
ref,
uiState,
uiStateNotifier,
rows,
)
: _buildNormalAppBar(
context,
ref,
treeController,
expandedGuids,
hideEmptyRoots,
expandedGuids,
textFilterEnabled,
textFilterController,
uiStateNotifier,
@@ -121,117 +153,173 @@ class BookmarkListScreen extends HookConsumerWidget {
body: SafeArea(
child: Padding(
padding: const EdgeInsets.only(left: 12.0),
child: bookmarkList.when(
skipLoadingOnReload: true,
data: (list) {
final sortedList = list != null
? sortBookmarkTree(
list,
uiState.sortType,
isRoot: entryGuid == BookmarkRoot.root.id,
)
: null;
TreeNode<BookmarkItem> addChildren(
TreeNode<BookmarkItem>? parent,
BookmarkItem item,
) {
if (uiState.foldersOnly && item is BookmarkEntry) {
return parent ?? TreeNode<BookmarkItem>.root();
}
final node = TreeNode(
key: item.guid,
data: item,
parent: parent,
);
final targetNode = (parent?..add(node)) ?? node;
if (item is BookmarkFolder && item.children != null) {
for (final child in item.children!) {
addChildren(node, child);
}
}
return targetNode;
}
final root = (sortedList != null)
? addChildren(null, sortedList)
: TreeNode<BookmarkItem>.root();
return TreeView.simple(
key: ValueKey((uiState.sortType, uiState.foldersOnly)),
tree: root,
showRootNode: entryGuid != BookmarkRoot.root.id,
onTreeReady: (controller) {
treeController.value = controller;
if (expandedGuids.value.isNotEmpty) {
_restoreExpansion(controller, root, expandedGuids.value);
} else {
controller.expandAllChildren(root, recursive: true);
}
},
expansionIndicatorBuilder: (context, tree) =>
ChevronIndicator.upDown(
tree: tree,
padding: const EdgeInsets.symmetric(
vertical: 16.0,
horizontal: 12.0,
),
child: isSearching
? _buildRowList(
context,
ref,
rows,
uiState,
uiStateNotifier,
expandedGuids,
emptyLabel: searchSuppressedByFilter
? 'Search matches bookmarks, which "Folders Only" is '
'hiding'
: 'No bookmarks match "${searchQuery.value}"',
)
: folderAsync.when(
skipLoadingOnReload: true,
data: (_) => _buildRowList(
context,
ref,
rows,
uiState,
uiStateNotifier,
expandedGuids,
emptyLabel: 'Empty',
),
error: (error, stackTrace) => Center(
child: FailureWidget(
title: 'Failed to load Bookmarks',
exception: error,
onRetry: () {
ref.invalidate(bookmarkFolderProvider(entryGuid));
},
),
builder: (context, item) {
final BookmarkItem? data = item.data;
final isSelected = uiState.selectedGuids.contains(
data?.guid,
);
final bool isLeaf = item.isLeaf;
final bool isExpanded = item.isExpanded;
return switch (data) {
final BookmarkEntry bookmark => _buildEntryTile(
context,
ref,
bookmark,
uiState,
uiStateNotifier,
isSelected,
sortedList,
),
final BookmarkFolder folder => _buildFolderTile(
context,
ref,
folder,
isLeaf: isLeaf,
isExpanded: isExpanded,
uiState: uiState,
uiStateNotifier: uiStateNotifier,
isSelected: isSelected,
rootItem: sortedList,
),
null => const Center(child: Text('Empty')),
};
},
);
},
error: (error, stackTrace) => Center(
child: FailureWidget(
title: 'Failed to load Bookmarks',
exception: error,
onRetry: () {
// ignore: unused_result
ref.refresh(bookmarksProvider<BookmarkItem>(entryGuid));
},
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
loading: () =>
const Center(child: CircularProgressIndicator()),
),
),
),
),
);
}
/// Flattens the entry folder and every expanded folder beneath it into rows.
///
/// A folder's children are only requested once it is expanded, so a collapsed
/// subtree costs nothing — this is what keeps the screen openable after a
/// large import or sync. Watching the child providers here means expanding
/// starts the load and the row list rebuilds when it lands.
List<BookmarkRow> _buildRows(
WidgetRef ref,
BookmarkFolder? folder,
BookmarkListUiState uiState,
Set<String> expandedGuids, {
required int depth,
}) {
final rows = <BookmarkRow>[];
for (final item in _visibleChildren(folder, uiState, depth: depth)) {
rows.add(BookmarkRow(item, depth));
if (item is! BookmarkFolder || !expandedGuids.contains(item.guid)) {
continue;
}
final child = ref.watch(bookmarkFolderProvider(item.guid));
if (child.value == null) {
rows.add(BookmarkRow(item, depth + 1, isPlaceholder: true));
continue;
}
rows.addAll(
_buildRows(ref, child.value, uiState, expandedGuids, depth: depth + 1),
);
}
return rows;
}
/// The children of [folder] in display order, with the folders-only filter
/// applied.
List<BookmarkItem> _visibleChildren(
BookmarkFolder? folder,
BookmarkListUiState uiState, {
required int depth,
}) {
var children = folder?.children ?? const <BookmarkItem>[];
if (uiState.foldersOnly) {
children = children.whereType<BookmarkFolder>().toList();
}
return sortBookmarkChildren(
children,
uiState.sortType,
// Only the entry level can be the tree root, and only there do the
// built-in folders need pinning.
isRoot: depth == 0 && entryGuid == BookmarkRoot.root.id,
);
}
/// Renders the flattened rows lazily.
///
/// [ListView.builder] only builds the tiles actually on screen, so expanding
/// a folder with thousands of entries stays as cheap as a small one.
Widget _buildRowList(
BuildContext context,
WidgetRef ref,
List<BookmarkRow> rows,
BookmarkListUiState uiState,
BookmarkListUiStateNotifier uiStateNotifier,
ValueNotifier<Set<String>> expandedGuids, {
required String emptyLabel,
}) {
if (rows.isEmpty) {
return Center(child: Text(emptyLabel));
}
return ListView.builder(
itemCount: rows.length,
itemBuilder: (context, index) {
final row = rows[index];
final item = row.item;
final isSelected = uiState.selectedGuids.contains(item.guid);
return Padding(
padding: EdgeInsets.only(left: row.depth * _indentPerDepth),
child: row.isPlaceholder
? const Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: SizedBox(
height: 16.0,
width: 16.0,
child: CircularProgressIndicator(strokeWidth: 2.0),
),
),
)
: switch (item) {
final BookmarkEntry bookmark => _buildEntryTile(
context,
ref,
bookmark,
uiState,
uiStateNotifier,
isSelected,
),
final BookmarkFolder folder => _buildFolderTile(
context,
ref,
folder,
uiState: uiState,
uiStateNotifier: uiStateNotifier,
isSelected: isSelected,
isExpanded: expandedGuids.value.contains(folder.guid),
onToggleExpanded: () {
final next = {...expandedGuids.value};
if (!next.remove(folder.guid)) next.add(folder.guid);
expandedGuids.value = next;
},
),
},
);
},
);
}
// -- App Bars --
PreferredSizeWidget _buildSelectionAppBar(
@@ -239,6 +327,7 @@ class BookmarkListScreen extends HookConsumerWidget {
WidgetRef ref,
BookmarkListUiState uiState,
BookmarkListUiStateNotifier uiStateNotifier,
List<BookmarkRow> rows,
) {
final count = uiState.selectedGuids.length;
return AppBar(
@@ -252,19 +341,21 @@ class BookmarkListScreen extends HookConsumerWidget {
icon: const Icon(MdiIcons.tabPlus),
tooltip: 'Open in background',
onPressed: count > 0
? () => _bulkOpenInBackground(context, ref, uiState)
? () => _bulkOpenInBackground(context, ref, uiState, rows)
: null,
),
IconButton(
icon: const Icon(MdiIcons.folderMove),
tooltip: 'Move selected',
onPressed: count > 0 ? () => _bulkMove(context, ref, uiState) : null,
onPressed: count > 0
? () => _bulkMove(context, ref, uiState, rows)
: null,
),
IconButton(
icon: const Icon(MdiIcons.delete),
tooltip: 'Delete selected',
onPressed: count > 0
? () => _bulkDelete(context, ref, uiState, uiStateNotifier)
? () => _bulkDelete(context, ref, uiState, uiStateNotifier, rows)
: null,
),
],
@@ -274,10 +365,8 @@ class BookmarkListScreen extends HookConsumerWidget {
AppBar _buildNormalAppBar(
BuildContext context,
WidgetRef ref,
ValueNotifier<TreeViewController<BookmarkItem, TreeNode<BookmarkItem>>?>
treeController,
ObjectRef<Set<String>> expandedGuids,
ValueNotifier<bool> hideEmptyRoots,
ValueNotifier<Set<String>> expandedGuids,
ValueNotifier<bool> textFilterEnabled,
TextEditingController textFilterController,
BookmarkListUiStateNotifier uiStateNotifier,
@@ -315,21 +404,41 @@ class BookmarkListScreen extends HookConsumerWidget {
),
MenuAnchor(
menuChildren: [
// Adding is otherwise only reachable from a child folder's row
// menu, which leaves an empty folder with no way to fill it.
// BookmarkRoot.root holds only the built-in folders, so it takes
// no children of its own.
if (entryGuid != BookmarkRoot.root.id) ...[
MenuItemButton(
leadingIcon: const Icon(MdiIcons.bookmarkPlus),
child: const Text('Add Bookmark Here'),
onPressed: () async {
await BookmarkEntryAddRoute(
bookmarkInfo: jsonEncode(
BookmarkInfo(parentGuid: entryGuid).encode(),
),
).push(context);
},
),
MenuItemButton(
leadingIcon: const Icon(MdiIcons.folderPlus),
child: const Text('Add Subfolder Here'),
onPressed: () async {
await BookmarkFolderAddRoute(
parentGuid: entryGuid,
).push(context);
},
),
],
SubmenuButton(
leadingIcon: const Icon(MdiIcons.eye),
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(MdiIcons.expandAll),
child: const Text('Expand All'),
onPressed: () {
final controller = treeController.value;
if (controller != null) {
controller.expandAllChildren(
controller.tree,
recursive: true,
);
}
},
leadingIcon: const Icon(MdiIcons.collapseAll),
onPressed: expandedGuids.value.isEmpty
? null
: () => expandedGuids.value = <String>{},
child: const Text('Collapse All'),
),
if (entryGuid == BookmarkRoot.root.id)
MenuItemButton(
@@ -353,13 +462,10 @@ class BookmarkListScreen extends HookConsumerWidget {
? MdiIcons.bookmarkMultiple
: MdiIcons.folderOutline,
),
onPressed: uiStateNotifier.toggleFoldersOnly,
child: Text(
uiState.foldersOnly ? 'Show Bookmarks' : 'Folders Only',
),
onPressed: () {
_snapshotExpansion(treeController.value, expandedGuids);
uiStateNotifier.toggleFoldersOnly();
},
),
],
child: const Text('Visibility'),
@@ -373,10 +479,7 @@ class BookmarkListScreen extends HookConsumerWidget {
? const Icon(Icons.check)
: const SizedBox(width: 24),
child: Text(sortType.label),
onPressed: () {
_snapshotExpansion(treeController.value, expandedGuids);
uiStateNotifier.setSortType(sortType);
},
onPressed: () => uiStateNotifier.setSortType(sortType),
),
],
child: const Text('Sort'),
@@ -387,12 +490,14 @@ class BookmarkListScreen extends HookConsumerWidget {
MenuItemButton(
leadingIcon: const Icon(MdiIcons.codeJson),
child: const Text('JSON'),
onPressed: () => _handleImport(context, ref, 'json'),
onPressed: () =>
_handleImport(context, ref, BookmarkImportFormat.json),
),
MenuItemButton(
leadingIcon: const Icon(MdiIcons.xml),
child: const Text('HTML'),
onPressed: () => _handleImport(context, ref, 'html'),
onPressed: () =>
_handleImport(context, ref, BookmarkImportFormat.html),
),
],
child: const Text('Import'),
@@ -438,7 +543,6 @@ class BookmarkListScreen extends HookConsumerWidget {
BookmarkListUiState uiState,
BookmarkListUiStateNotifier uiStateNotifier,
bool isSelected,
BookmarkItem? rootItem,
) {
if (uiState.selectionMode) {
return ListTile(
@@ -463,7 +567,7 @@ class BookmarkListScreen extends HookConsumerWidget {
key: ValueKey(bookmark.guid),
contentPadding: EdgeInsets.zero,
leading: UrlIcon([bookmark.url], iconSize: 34.0),
trailing: _buildEntryMenu(context, ref, bookmark, rootItem),
trailing: _buildEntryMenu(context, ref, bookmark),
title: Text(bookmark.title, maxLines: 3, overflow: TextOverflow.ellipsis),
subtitle: UriBreadcrumb(uri: bookmark.url),
onTap: () async {
@@ -487,7 +591,6 @@ class BookmarkListScreen extends HookConsumerWidget {
BuildContext context,
WidgetRef ref,
BookmarkEntry bookmark,
BookmarkItem? rootItem,
) {
return HookBuilder(
builder: (context) {
@@ -605,26 +708,21 @@ class BookmarkListScreen extends HookConsumerWidget {
BuildContext context,
WidgetRef ref,
BookmarkFolder folder, {
required bool isLeaf,
required bool isExpanded,
required BookmarkListUiState uiState,
required BookmarkListUiStateNotifier uiStateNotifier,
required bool isSelected,
required BookmarkItem? rootItem,
required bool isExpanded,
required VoidCallback onToggleExpanded,
}) {
final isRoot = bookmarkRootIds.contains(folder.guid);
if (uiState.selectionMode) {
return Padding(
key: ValueKey(folder.guid),
padding: isLeaf
? const EdgeInsets.only(right: 4.0)
: const EdgeInsets.only(right: 42.0),
padding: const EdgeInsets.only(right: 4.0),
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: isExpanded
? const Icon(MdiIcons.folderOpen)
: const Icon(MdiIcons.folder),
leading: Icon(isExpanded ? MdiIcons.folderOpen : MdiIcons.folder),
title: Text(folder.title),
trailing: isRoot
? null
@@ -642,18 +740,21 @@ class BookmarkListScreen extends HookConsumerWidget {
return Padding(
key: ValueKey(folder.guid),
padding: isLeaf
? const EdgeInsets.only(right: 4.0)
: const EdgeInsets.only(right: 42.0),
padding: const EdgeInsets.only(right: 4.0),
child: HookBuilder(
builder: (context) {
final controller = useMenuController();
return ListTile(
contentPadding: EdgeInsets.zero,
leading: isExpanded
? const Icon(MdiIcons.folderOpen)
: const Icon(MdiIcons.folder),
// Whether a folder has children is unknown until it is opened, so
// every folder offers the toggle. Tapping the row still navigates
// into it, as it did before.
leading: IconButton(
icon: Icon(isExpanded ? MdiIcons.folderOpen : MdiIcons.folder),
tooltip: isExpanded ? 'Collapse' : 'Expand',
onPressed: onToggleExpanded,
),
title: Text(folder.title),
trailing: MenuAnchor(
controller: controller,
@@ -777,15 +878,14 @@ class BookmarkListScreen extends HookConsumerWidget {
BuildContext context,
WidgetRef ref,
BookmarkListUiState uiState,
List<BookmarkRow> rows,
) async {
final bookmarkData = ref.read(
seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true),
);
final root = bookmarkData.value;
if (root == null) return;
final items = resolveSelectedItems(root, uiState.selectedGuids);
final entries = items.whereType<BookmarkEntry>().toList();
// Not normalised: opening is additive, so a bookmark selected inside an
// also-selected folder should still open rather than be dropped.
final entries = resolveSelectedItems([
for (final row in rows)
if (!row.isPlaceholder) row.item,
], uiState.selectedGuids).whereType<BookmarkEntry>().toList();
if (entries.isEmpty) {
if (context.mounted) {
@@ -820,18 +920,14 @@ class BookmarkListScreen extends HookConsumerWidget {
BuildContext context,
WidgetRef ref,
BookmarkListUiState uiState,
List<BookmarkRow> rows,
) async {
final bookmarkData = ref.read(
seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true),
);
final root = bookmarkData.value;
if (root == null) return;
final items = _selectedItems(rows, uiState.selectedGuids);
if (items.isEmpty) return;
final items = resolveSelectedItems(root, uiState.selectedGuids);
// Build exclusion set from selected folders and their full descendant
// trees fetched from storage, so hidden folders (e.g. filtered by search)
// are still properly excluded as move targets.
// A folder cannot be moved inside itself, so exclude each selected folder
// and its descendants. Fetched from storage rather than read off the list,
// which only knows the level currently on screen.
final repo = ref.read(bookmarksRepositoryProvider.notifier);
final excludeGuids = <String>{};
for (final item in items) {
@@ -850,18 +946,12 @@ class BookmarkListScreen extends HookConsumerWidget {
if (targetGuid == null) return;
// Normalize selection to avoid double-moves
final normalizedGuids = normalizeSelection(root, uiState.selectedGuids);
final normalizedItems = resolveSelectedItems(root, normalizedGuids);
await ref
.read(bookmarksRepositoryProvider.notifier)
.moveMany(items: normalizedItems, targetParentGuid: targetGuid);
await repo.moveMany(items: items, targetParentGuid: targetGuid);
ref.read(bookmarkListUiStateProvider.notifier).exitSelectionMode();
if (context.mounted) {
showInfoMessage(context, 'Moved ${normalizedItems.length} items');
showInfoMessage(context, 'Moved ${items.length} items');
}
}
@@ -870,81 +960,56 @@ class BookmarkListScreen extends HookConsumerWidget {
WidgetRef ref,
BookmarkListUiState uiState,
BookmarkListUiStateNotifier uiStateNotifier,
List<BookmarkRow> rows,
) async {
final bookmarkData = ref.read(
seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true),
);
final root = bookmarkData.value;
if (root == null) return;
final items = _selectedItems(rows, uiState.selectedGuids);
if (items.isEmpty) return;
final items = resolveSelectedItems(root, uiState.selectedGuids);
final hasFolders = items.any((item) => item is BookmarkFolder);
final folderGuids = items
.whereType<BookmarkFolder>()
.map((folder) => folder.guid)
.toList();
// Deleting a folder takes everything under it, which the user cannot see
// from here — so say how much before asking.
final nestedCount = folderGuids.isEmpty
? 0
: await ref
.read(bookmarksRepositoryProvider.notifier)
.countBookmarksInTrees(folderGuids);
if (!context.mounted) return;
final result = await (hasFolders
? showDeleteFolderDialog(context)
final result = await (folderGuids.isNotEmpty
? showDeleteFolderDialog(context, bookmarkCount: nestedCount)
: showDeleteBookmarkDialog(context));
if (result != true) return;
// Normalize to avoid deleting children whose parent folder is also being deleted
final normalizedGuids = normalizeSelection(root, uiState.selectedGuids);
await ref
.read(bookmarksRepositoryProvider.notifier)
.deleteMany(normalizedGuids);
final guids = items.map((item) => item.guid).toSet();
await ref.read(bookmarksRepositoryProvider.notifier).deleteMany(guids);
uiStateNotifier.exitSelectionMode();
if (context.mounted) {
showInfoMessage(context, 'Deleted ${normalizedGuids.length} items');
showInfoMessage(context, 'Deleted ${guids.length} items');
}
}
// -- Tree Expansion State Helpers --
/// Collects the GUIDs of all currently expanded nodes from the tree.
void _snapshotExpansion(
TreeViewController<BookmarkItem, TreeNode<BookmarkItem>>? controller,
ObjectRef<Set<String>> expandedGuids,
/// The selected items, with anything nested inside another selected folder
/// dropped.
///
/// Expanding a folder puts its children on screen next to it, so a user can
/// select both — acting on each in turn would move a child out of the folder
/// that just moved, or delete it a second time.
List<BookmarkItem> _selectedItems(
List<BookmarkRow> rows,
Set<String> selectedGuids,
) {
if (controller == null) return;
final guids = <String>{};
_collectExpandedGuids(controller.tree, guids);
expandedGuids.value = guids;
}
final guids = normalizeSelection(rows, selectedGuids);
void _collectExpandedGuids(TreeNode<BookmarkItem> node, Set<String> guids) {
if (node.isExpanded && node.key != INode.ROOT_KEY) {
guids.add(node.key);
}
for (final child in node.childrenAsList) {
_collectExpandedGuids(child as TreeNode<BookmarkItem>, guids);
}
}
/// Restores expansion state by expanding nodes whose GUIDs are in the set.
void _restoreExpansion(
TreeViewController<BookmarkItem, TreeNode<BookmarkItem>> controller,
TreeNode<BookmarkItem> root,
Set<String> guids,
) {
// Always expand root
controller.expandNode(root);
_expandMatchingNodes(controller, root, guids);
}
void _expandMatchingNodes(
TreeViewController<BookmarkItem, TreeNode<BookmarkItem>> controller,
TreeNode<BookmarkItem> node,
Set<String> guids,
) {
for (final child in node.childrenAsList) {
final typedChild = child as TreeNode<BookmarkItem>;
if (guids.contains(typedChild.key)) {
controller.expandNode(typedChild);
}
_expandMatchingNodes(controller, typedChild, guids);
}
return resolveSelectedItems([
for (final row in rows)
if (!row.isPlaceholder) row.item,
], guids);
}
// -- Tab Opening Helper --
@@ -995,12 +1060,14 @@ class BookmarkListScreen extends HookConsumerWidget {
Future<void> _handleImport(
BuildContext context,
WidgetRef ref,
String format,
BookmarkImportFormat format,
) async {
try {
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: format == 'json' ? ['json'] : ['html', 'htm'],
allowedExtensions: format == BookmarkImportFormat.json
? ['json']
: ['html', 'htm'],
);
if (result == null || result.files.isEmpty) return;
@@ -1019,12 +1086,15 @@ class BookmarkListScreen extends HookConsumerWidget {
final shouldReplace = await showImportBookmarksDialog(context);
if (shouldReplace == null) return; // User cancelled dialog
final content = await File(file.path!).readAsString();
final repository = ref.read(bookmarksRepositoryProvider.notifier);
final count = format == 'json'
? await repository.importFromJSON(content, replace: shouldReplace)
: await repository.importFromHTML(content, replace: shouldReplace);
// Reading and parsing happen in a background isolate, so a large file
// does not freeze the UI while it is being processed.
final count = await ref
.read(bookmarksRepositoryProvider.notifier)
.importFromFile(
path: file.path!,
format: format,
replace: shouldReplace,
);
if (context.mounted) {
showInfoMessage(context, 'Imported $count bookmarks successfully');
@@ -17,7 +17,6 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:animated_tree_view/animated_tree_view.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
@@ -28,11 +27,19 @@ import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/b
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
/// Indentation applied per level of folder nesting.
const _indentPerDepth = 20.0;
/// A widget that displays a tree view of bookmark folders and allows the user
/// to select a parent folder.
///
/// Folders are loaded one level at a time as the user expands them, so opening
/// the picker never pulls in the whole bookmark tree.
///
/// When editing a folder, pass [excludeFolderGuids] to prevent selecting the
/// folders or their descendants as the parent (which would create a circular reference).
/// folders or their descendants as the parent (which would create a circular
/// reference). Descendants are excluded implicitly: an excluded folder is never
/// rendered, so nothing underneath it can be reached or expanded.
class FolderTreePicker extends HookConsumerWidget {
/// The currently selected folder GUID
final ValueNotifier<String> selectedFolderGuid;
@@ -52,99 +59,39 @@ class FolderTreePicker extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final treeKey = useMemoized(() => GlobalKey<TreeViewState>());
// Folders start collapsed apart from the entry point, so the picker opens
// after a single shallow load.
final expandedGuids = useState(<String>{entryGuid});
final folderList = ref.watch(bookmarksProvider<BookmarkFolder>(entryGuid));
final rootFolder = ref.watch(bookmarkFolderProvider(entryGuid));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Folder', style: Theme.of(context).textTheme.labelMedium),
folderList.when(
rootFolder.when(
skipLoadingOnReload: true,
data: (list) {
TreeNode<BookmarkFolder> addChildren(
TreeNode<BookmarkFolder>? parent,
BookmarkFolder item,
) {
final node = TreeNode(key: item.guid, data: item, parent: parent);
final targetNode = (parent?..add(node)) ?? node;
data: (folder) {
if (folder == null) return const SizedBox.shrink();
if (item.children != null) {
for (final child in item.children!) {
// Skip excluded folders and their descendants
if (child is BookmarkFolder &&
!excludeFolderGuids.contains(child.guid)) {
addChildren(node, child);
}
}
}
return targetNode;
}
final root = (list != null)
? addChildren(null, list)
: TreeNode<BookmarkFolder>.root();
return TreeView.simple(
key: treeKey,
tree: root,
shrinkWrap: true,
showRootNode: entryGuid != BookmarkRoot.root.id,
onTreeReady: (controller) {
controller.expandAllChildren(root, recursive: true);
},
expansionIndicatorBuilder: (context, tree) =>
ChevronIndicator.upDown(
tree: tree,
padding: const EdgeInsets.symmetric(
vertical: 16.0,
horizontal: 12.0,
),
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (entryGuid != BookmarkRoot.root.id)
_FolderRow(
folder: folder,
depth: 0,
expandedGuids: expandedGuids,
selectedFolderGuid: selectedFolderGuid,
),
builder: (context, item) {
final isSelected = item.data?.guid == selectedFolderGuid.value;
// BookmarkRoot.root cannot be selected as a parent
final isRootFolder = item.data?.guid == BookmarkRoot.root.id;
return Padding(
padding: const EdgeInsets.only(right: 42.0),
child: switch (item.data) {
final BookmarkFolder folder => ListTile(
key: ValueKey(folder.guid),
contentPadding: EdgeInsets.zero,
selected: isSelected,
enabled: !isRootFolder,
leading: (item.isExpanded)
? const Icon(MdiIcons.folderOpen)
: const Icon(MdiIcons.folder),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelected) const Icon(Icons.check),
IconButton(
onPressed: () async {
await BookmarkFolderAddRoute(
parentGuid: folder.guid,
).push(context);
},
icon: const Icon(MdiIcons.folderPlus),
),
],
),
title: Text(folder.title),
onTap: !isRootFolder
? () {
selectedFolderGuid.value = folder.guid;
}
: null,
),
null => const SizedBox.shrink(),
},
);
},
_FolderChildren(
parentGuid: folder.guid,
depth: entryGuid != BookmarkRoot.root.id ? 1 : 0,
expandedGuids: expandedGuids,
selectedFolderGuid: selectedFolderGuid,
excludeFolderGuids: excludeFolderGuids,
),
],
);
},
error: (error, stackTrace) => Center(
@@ -152,7 +99,7 @@ class FolderTreePicker extends HookConsumerWidget {
title: 'Failed to load Bookmark Folders',
exception: error,
onRetry: () {
ref.invalidate(bookmarksProvider<BookmarkFolder>(entryGuid));
ref.invalidate(bookmarkFolderProvider(entryGuid));
},
),
),
@@ -162,3 +109,133 @@ class FolderTreePicker extends HookConsumerWidget {
);
}
}
/// The direct subfolders of [parentGuid], rendered only while its parent is
/// expanded.
///
/// Mounting this widget is what triggers the load, so a collapsed folder costs
/// nothing.
class _FolderChildren extends HookConsumerWidget {
final String parentGuid;
final int depth;
final ValueNotifier<Set<String>> expandedGuids;
final ValueNotifier<String> selectedFolderGuid;
final Set<String> excludeFolderGuids;
const _FolderChildren({
required this.parentGuid,
required this.depth,
required this.expandedGuids,
required this.selectedFolderGuid,
required this.excludeFolderGuids,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final folder = ref.watch(bookmarkFolderProvider(parentGuid));
final subfolders = (folder.value?.children ?? const <BookmarkItem>[])
.whereType<BookmarkFolder>()
.where((child) => !excludeFolderGuids.contains(child.guid))
.toList();
if (folder.isLoading && folder.value == null) {
return Padding(
padding: EdgeInsets.only(left: depth * _indentPerDepth, top: 8.0),
child: const Align(
alignment: Alignment.centerLeft,
child: SizedBox(
height: 16.0,
width: 16.0,
child: CircularProgressIndicator(strokeWidth: 2.0),
),
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final subfolder in subfolders) ...[
_FolderRow(
folder: subfolder,
depth: depth,
expandedGuids: expandedGuids,
selectedFolderGuid: selectedFolderGuid,
),
if (expandedGuids.value.contains(subfolder.guid))
_FolderChildren(
parentGuid: subfolder.guid,
depth: depth + 1,
expandedGuids: expandedGuids,
selectedFolderGuid: selectedFolderGuid,
excludeFolderGuids: excludeFolderGuids,
),
],
],
);
}
}
class _FolderRow extends HookConsumerWidget {
final BookmarkFolder folder;
final int depth;
final ValueNotifier<Set<String>> expandedGuids;
final ValueNotifier<String> selectedFolderGuid;
const _FolderRow({
required this.folder,
required this.depth,
required this.expandedGuids,
required this.selectedFolderGuid,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isExpanded = expandedGuids.value.contains(folder.guid);
final isSelected = folder.guid == selectedFolderGuid.value;
// BookmarkRoot.root cannot be selected as a parent
final isRootFolder = folder.guid == BookmarkRoot.root.id;
return Padding(
padding: EdgeInsets.only(left: depth * _indentPerDepth, right: 42.0),
child: ListTile(
key: ValueKey(folder.guid),
contentPadding: EdgeInsets.zero,
selected: isSelected,
enabled: !isRootFolder,
// Whether a folder has subfolders is unknown until it is opened, so
// every folder offers the toggle.
leading: IconButton(
icon: Icon(isExpanded ? MdiIcons.folderOpen : MdiIcons.folder),
onPressed: () {
expandedGuids.value = isExpanded
? ({...expandedGuids.value}..remove(folder.guid))
: ({...expandedGuids.value}..add(folder.guid));
},
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelected) const Icon(Icons.check),
IconButton(
onPressed: () async {
await BookmarkFolderAddRoute(
parentGuid: folder.guid,
).push(context);
},
icon: const Icon(MdiIcons.folderPlus),
),
],
),
title: Text(folder.title),
onTap: !isRootFolder
? () {
selectedFolderGuid.value = folder.guid;
}
: null,
),
);
}
}
@@ -20,7 +20,8 @@
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_importer.dart';
const _containerNormal = 0;
const _containerToolbar = 1;
@@ -30,18 +31,22 @@ const _containerPlaces = 4;
const _exportIndent = ' ';
class _Frame {
final Map<String, dynamic> folder;
int containerNesting = 0;
int lastContainerType = _containerNormal;
String previousText = '';
bool inDescription = false;
String? previousLink;
Map<String, dynamic>? previousItem;
DateTime? previousDateAdded;
DateTime? previousLastModifiedDate;
_Frame(this.folder);
/// Parses a Netscape bookmark file into an [ImportBookmarkTree].
///
/// Pure and free of platform channels, so it is safe to run inside an isolate;
/// see `bookmark_import_isolate.dart`.
///
/// When [preserveRootFolders] is set, folders carrying Firefox root markers
/// (`PERSONAL_TOOLBAR_FOLDER`, `BOOKMARKS_MENU`, `UNFILED_BOOKMARKS_FOLDER`,
/// `PLACES_ROOT`) at the top level are routed to the matching Places root
/// instead of being imported as ordinary folders. Everything else lands under
/// [BookmarkRoot.menu].
ImportBookmarkTree parseBookmarkHtml(
String htmlString, {
required bool preserveRootFolders,
}) {
final parser = _BookmarkHtmlParser(preserveRootFolders: preserveRootFolders);
return parser.parse(htmlString);
}
class BookmarkHTMLUtils {
@@ -50,9 +55,9 @@ class BookmarkHTMLUtils {
BookmarkHTMLUtils(this._service);
/// Import bookmarks from HTML string
Future<int> importFromHTML(String htmlString, {bool replace = false}) async {
final importer = _BookmarkImporter(_service, replace);
return await importer.importFromHTML(htmlString);
Future<int> importFromHTML(String htmlString, {bool replace = false}) {
final tree = parseBookmarkHtml(htmlString, preserveRootFolders: replace);
return BookmarkTreeImporter(_service).import(tree, replace: replace);
}
/// Export bookmarks to HTML string
@@ -67,27 +72,80 @@ class BookmarkHTMLUtils {
}
}
class _BookmarkImporter {
final GeckoBookmarksService _service;
final bool _isImportDefaults;
final Map<String, dynamic> _bookmarkTree;
/// A node collected while parsing.
///
/// Folders stay mutable until their closing tag so children can be appended in
/// place; leaves are immutable as soon as they are complete.
sealed class _ParsedNode {}
final class _ParsedLeaf extends _ParsedNode {
final ImportBookmarkNode node;
_ParsedLeaf(this.node);
}
final class _ParsedFolder extends _ParsedNode {
String title = '';
/// Set when the folder carried a Firefox root marker, naming the Places root
/// its children belong to.
String? rootGuid;
DateTime? dateAdded;
DateTime? lastModified;
final List<_ParsedNode> children = [];
}
/// A bookmark whose `<A>` tag has been opened but whose title text has not been
/// read yet.
class _PendingItem {
final Uri url;
final DateTime? dateAdded;
final DateTime? lastModified;
_PendingItem({required this.url, this.dateAdded, this.lastModified});
}
class _Frame {
final _ParsedFolder folder;
int containerNesting = 0;
int lastContainerType = _containerNormal;
String previousText = '';
bool inDescription = false;
_PendingItem? pendingItem;
DateTime? previousDateAdded;
DateTime? previousLastModifiedDate;
_Frame(this.folder);
}
class _BookmarkHtmlParser {
final bool preserveRootFolders;
final _ParsedFolder _root = _ParsedFolder();
final List<_Frame> _frames = [];
_BookmarkImporter(this._service, this._isImportDefaults)
: _bookmarkTree = {
'type': BookmarkNodeType.folder.index,
'guid': BookmarkRoot.menu.id,
'children': <Map<String, dynamic>>[],
} {
_frames.add(_Frame(_bookmarkTree));
int _bookmarkCount = 0;
int _folderCount = 0;
int _separatorCount = 0;
int _skippedUrlCount = 0;
_BookmarkHtmlParser({required this.preserveRootFolders}) {
_frames.add(_Frame(_root));
}
_Frame get _curFrame => _frames.last;
Future<int> importFromHTML(String htmlString) async {
ImportBookmarkTree parse(String htmlString) {
final document = html_parser.parse(htmlString);
_walkTreeForImport(document.body);
return await _importBookmarks();
// Close whatever the document left open so nothing is dropped.
while (_frames.length > 1) {
_popFrame();
}
_flushPendingItem();
return _buildTree();
}
dom.Node? _nextSibling(dom.Node node) {
@@ -186,29 +244,30 @@ class _BookmarkImporter {
}
void _handleHeadBegin(dom.Element element) {
final frame = _curFrame;
frame.previousLink = null;
frame.lastContainerType = _containerNormal;
if (frame.containerNesting == 0 && _frames.length > 1) {
_frames.removeLast();
// A heading that arrives while the current folder never opened its `<DL>`
// closes that folder first. Everything below must describe the *new*
// heading, so the frame is only captured once the stack has settled.
if (_curFrame.containerNesting == 0 && _frames.length > 1) {
_popFrame();
}
final frame = _curFrame;
frame.lastContainerType = _containerNormal;
if (element.attributes.containsKey('personal_toolbar_folder')) {
if (_isImportDefaults) {
if (preserveRootFolders) {
frame.lastContainerType = _containerToolbar;
}
} else if (element.attributes.containsKey('bookmarks_menu')) {
if (_isImportDefaults) {
if (preserveRootFolders) {
frame.lastContainerType = _containerMenu;
}
} else if (element.attributes.containsKey('unfiled_bookmarks_folder')) {
if (_isImportDefaults) {
if (preserveRootFolders) {
frame.lastContainerType = _containerUnfiled;
}
} else if (element.attributes.containsKey('places_root')) {
if (_isImportDefaults) {
if (preserveRootFolders) {
frame.lastContainerType = _containerPlaces;
}
} else {
@@ -227,67 +286,76 @@ class _BookmarkImporter {
}
void _handleLinkBegin(dom.Element element) {
final frame = _curFrame;
// An unterminated `<A>` must not swallow the one that follows it.
_flushPendingItem();
frame.previousItem = null;
final frame = _curFrame;
frame.previousText = '';
// TAGS, SHORTCUTURL, POST_DATA and LAST_CHARSET are read by Firefox but
// have no representation in Places' bookmark storage, so they are dropped.
final href = element.attributes['href']?.trim();
final dateAdded = element.attributes['add_date']?.trim();
final lastModified = element.attributes['last_modified']?.trim();
final tags = element.attributes['tags']?.trim();
final keyword = element.attributes['shortcuturl']?.trim();
final postData = element.attributes['post_data']?.trim();
final lastCharset = element.attributes['last_charset']?.trim();
if (href == null || href.isEmpty) {
frame.previousLink = null;
_skippedUrlCount++;
return;
}
final Uri url;
try {
final uri = Uri.parse(href);
if (!uri.hasScheme) {
frame.previousLink = null;
_skippedUrlCount++;
return;
}
frame.previousLink = uri.toString();
url = uri;
} catch (e) {
frame.previousLink = null;
_skippedUrlCount++;
return;
}
final bookmark = <String, dynamic>{'url': frame.previousLink};
final lastModifiedDate = lastModified != null
? _convertImportedDateToInternalDate(lastModified)
: null;
if (dateAdded != null) {
bookmark['dateAdded'] = _convertImportedDateToInternalDate(
dateAdded,
).millisecondsSinceEpoch;
}
if (lastModified != null) {
bookmark['lastModified'] = _convertImportedDateToInternalDate(
lastModified,
).millisecondsSinceEpoch;
}
if (dateAdded == null && lastModified != null) {
bookmark['dateAdded'] = bookmark['lastModified'];
}
frame.pendingItem = _PendingItem(
url: url,
// A bookmark that only records a modification time is treated as having
// been added then, matching Firefox's own importer.
dateAdded: dateAdded != null
? _convertImportedDateToInternalDate(dateAdded)
: lastModifiedDate,
lastModified: lastModifiedDate,
);
}
if (tags != null && tags.isNotEmpty) {
bookmark['tags'] = tags;
}
if (keyword != null && keyword.isNotEmpty) {
bookmark['keyword'] = keyword;
}
if (postData != null && postData.isNotEmpty) {
bookmark['postData'] = postData;
}
if (lastCharset != null && lastCharset.isNotEmpty) {
bookmark['charset'] = lastCharset;
}
/// Materialises the frame's open bookmark, if any, using [title].
void _flushPendingItem({String title = ''}) {
final frame = _curFrame;
final pending = frame.pendingItem;
if (pending == null) return;
(frame.folder['children'] as List).add(bookmark);
frame.previousItem = bookmark;
frame.pendingItem = null;
frame.folder.children.add(
_ParsedLeaf(
ImportBookmarkItem(
url: pending.url,
title: title,
dateAdded: pending.dateAdded,
lastModified: pending.lastModified,
),
),
);
_bookmarkCount++;
}
/// Completes the innermost folder and hands it to its parent.
void _popFrame() {
_flushPendingItem();
final frame = _frames.removeLast();
_curFrame.folder.children.add(frame.folder);
}
void _handleContainerBegin() {
@@ -300,7 +368,7 @@ class _BookmarkImporter {
frame.containerNesting--;
}
if (_frames.length > 1 && frame.containerNesting == 0) {
_frames.removeLast();
_popFrame();
}
}
@@ -310,163 +378,111 @@ class _BookmarkImporter {
void _handleLinkEnd() {
final frame = _curFrame;
frame.previousText = frame.previousText.trim();
if (frame.previousItem != null) {
frame.previousItem!['title'] = frame.previousText;
}
_flushPendingItem(title: frame.previousText.trim());
frame.previousText = '';
}
void _handleSeparator() {
final frame = _curFrame;
final separator = <String, dynamic>{
'type': BookmarkNodeType.separator.index,
};
(frame.folder['children'] as List).add(separator);
frame.previousItem = separator;
_flushPendingItem();
_curFrame.folder.children.add(_ParsedLeaf(const ImportBookmarkSeparator()));
_separatorCount++;
}
void _newFrame() {
_flushPendingItem();
final frame = _curFrame;
final containerTitle = frame.previousText;
frame.previousText = '';
final containerType = frame.lastContainerType;
final folder = <String, dynamic>{
'children': <Map<String, dynamic>>[],
'type': BookmarkNodeType.folder.index,
};
final folder = _ParsedFolder();
switch (containerType) {
switch (frame.lastContainerType) {
case _containerNormal:
folder['title'] = containerTitle;
folder.title = containerTitle;
case _containerPlaces:
folder['guid'] = BookmarkRoot.root.id;
folder.rootGuid = BookmarkRoot.root.id;
case _containerMenu:
folder['guid'] = BookmarkRoot.menu.id;
folder.rootGuid = BookmarkRoot.menu.id;
case _containerUnfiled:
folder['guid'] = BookmarkRoot.unfiled.id;
folder.rootGuid = BookmarkRoot.unfiled.id;
case _containerToolbar:
folder['guid'] = BookmarkRoot.toolbar.id;
folder.rootGuid = BookmarkRoot.toolbar.id;
}
(frame.folder['children'] as List).add(folder);
folder.lastModified = frame.previousLastModifiedDate;
// As for items, a folder that only records a modification time is treated
// as having been created then.
folder.dateAdded = frame.previousDateAdded ?? folder.lastModified;
frame.previousDateAdded = null;
frame.previousLastModifiedDate = null;
if (frame.previousDateAdded != null) {
folder['dateAdded'] = frame.previousDateAdded!.millisecondsSinceEpoch;
frame.previousDateAdded = null;
}
if (frame.previousLastModifiedDate != null) {
folder['lastModified'] =
frame.previousLastModifiedDate!.millisecondsSinceEpoch;
frame.previousLastModifiedDate = null;
}
if (!folder.containsKey('dateAdded') &&
folder.containsKey('lastModified')) {
folder['dateAdded'] = folder['lastModified'];
}
frame.previousItem = folder;
_frames.add(_Frame(folder));
}
DateTime _convertImportedDateToInternalDate(String seconds) {
try {
final parsed = int.tryParse(seconds);
if (parsed != null) {
return DateTime.fromMillisecondsSinceEpoch(parsed * 1000);
}
} catch (e) {
// Fall through
final parsed = int.tryParse(seconds);
if (parsed != null) {
return DateTime.fromMillisecondsSinceEpoch(parsed * 1000);
}
return DateTime.now();
}
List<Map<String, dynamic>> _getBookmarkTrees() {
if (!_isImportDefaults) {
return [_bookmarkTree];
/// Groups the parsed top level into per-root sections.
///
/// Only top-level folders carrying a root marker are routed to their own
/// Places root; a marker deeper in the file describes a folder that Firefox
/// itself would have nested, so it is imported as an ordinary (untitled)
/// folder.
ImportBookmarkTree _buildTree() {
final menuNodes = <ImportBookmarkNode>[];
final rootSections = <String, List<ImportBookmarkNode>>{};
for (final child in _root.children) {
if (child is _ParsedFolder && child.rootGuid != null) {
rootSections
.putIfAbsent(child.rootGuid!, () => <ImportBookmarkNode>[])
.addAll(child.children.map(_toImmutable));
} else {
menuNodes.add(_toImmutable(child));
}
}
final bookmarkTrees = <Map<String, dynamic>>[_bookmarkTree];
final children = _bookmarkTree['children'] as List<Map<String, dynamic>>;
final sections = <String, List<ImportBookmarkNode>>{
if (menuNodes.isNotEmpty) BookmarkRoot.menu.id: menuNodes,
};
for (final section in rootSections.entries) {
sections.update(
section.key,
(existing) => existing..addAll(section.value),
ifAbsent: () => section.value,
);
}
_bookmarkTree['children'] = children.where((child) {
final guid = child['guid'] as String?;
if (guid != null && bookmarkRootIds.contains(guid)) {
bookmarkTrees.add(child);
return false;
}
return true;
}).toList();
return bookmarkTrees;
return ImportBookmarkTree(
sections: sections,
stats: ImportBookmarkStats(
bookmarkCount: _bookmarkCount,
folderCount: _folderCount,
separatorCount: _separatorCount,
skippedUrlCount: _skippedUrlCount,
),
);
}
Future<int> _importBookmarks() async {
if (_isImportDefaults) {
// Delete bookmarks from each root folder (except root itself to avoid errors)
for (final root in BookmarkRoot.values) {
if (root != BookmarkRoot.root) {
await _service.eraseEverything(root);
}
}
ImportBookmarkNode _toImmutable(_ParsedNode node) {
switch (node) {
case final _ParsedLeaf leaf:
return leaf.node;
case final _ParsedFolder folder:
_folderCount++;
return ImportBookmarkFolder(
title: folder.title,
children: folder.children.map(_toImmutable).toList(),
dateAdded: folder.dateAdded,
lastModified: folder.lastModified,
);
}
final bookmarkTrees = _getBookmarkTrees();
int bookmarkCount = 0;
for (final tree in bookmarkTrees) {
final children = tree['children'] as List?;
if (children == null || children.isEmpty) continue;
bookmarkCount += await _insertTree(tree);
}
return bookmarkCount;
}
Future<int> _insertTree(Map<String, dynamic> node) async {
int count = 0;
final children = node['children'] as List?;
if (children == null || children.isEmpty) return 0;
final parentGuid = node['guid'] as String;
for (int i = 0; i < children.length; i++) {
final child = children[i] as Map<String, dynamic>;
final type = child['type'] as int? ?? BookmarkNodeType.item.index;
if (type == BookmarkNodeType.item.index) {
final url = child['url'] as String?;
final title = child['title'] as String? ?? '';
if (url != null && url.isNotEmpty) {
try {
final uri = Uri.parse(url);
if (uri.hasScheme) {
await _service.addItem(parentGuid, uri, title, i);
count++;
}
} catch (e) {
logger.e('Failed to import bookmark "$title": $e');
}
}
} else if (type == BookmarkNodeType.folder.index) {
final title = child['title'] as String? ?? '';
try {
final newGuid = await _service.addFolder(parentGuid, title, i);
child['guid'] = newGuid;
count += await _insertTree(child);
} catch (e) {
logger.e('Failed to import folder "$title": $e');
}
}
}
return count;
}
}
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:io';
import 'dart:isolate';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart';
/// The bookmark file formats WebLibre can read.
enum BookmarkImportFormat { html, json }
/// Reads and parses the bookmark file at [path] in a background isolate.
///
/// Both the file read and the parse happen off the UI isolate, so a large
/// export — the reported problem case is around 25k bookmarks — does not block
/// frames. Only the resulting [ImportBookmarkTree] crosses back, which is far
/// smaller than the HTML DOM or decoded JSON it was built from.
///
/// The file is read inside the isolate rather than handed over as a string, so
/// the UI isolate never holds the raw document in memory.
Future<ImportBookmarkTree> parseBookmarkFile({
required String path,
required BookmarkImportFormat format,
required bool preserveRootFolders,
}) {
return Isolate.run(
() => _parseBookmarkFile(path, format, preserveRootFolders),
debugName: 'bookmark-import-parse',
);
}
/// Runs inside the spawned isolate; must stay free of platform channels and of
/// anything tied to the UI isolate's state.
ImportBookmarkTree _parseBookmarkFile(
String path,
BookmarkImportFormat format,
bool preserveRootFolders,
) {
final content = File(path).readAsStringSync();
return switch (format) {
BookmarkImportFormat.html => parseBookmarkHtml(
content,
preserveRootFolders: preserveRootFolders,
),
BookmarkImportFormat.json => parseBookmarkJson(content),
};
}
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
/// Writes a parsed [ImportBookmarkTree] into Places.
///
/// Insertion is bulk: one native call per destination root, with each top-level
/// folder written as a single storage tree insertion. A 25k-bookmark file
/// therefore costs a handful of platform channel round trips rather than one
/// per node.
class BookmarkTreeImporter {
final GeckoBookmarksService _service;
const BookmarkTreeImporter(this._service);
/// Inserts [tree] and returns the number of bookmark items written.
///
/// When [replace] is set every root except [BookmarkRoot.root] is emptied
/// first; the root itself is never deleted. A tree that parsed to nothing
/// erases nothing either, so a malformed or empty file cannot wipe the
/// user's bookmarks and leave them with an empty library.
Future<int> import(ImportBookmarkTree tree, {required bool replace}) async {
if (tree.isEmpty) return 0;
if (replace) {
for (final root in BookmarkRoot.values) {
if (root != BookmarkRoot.root) {
await _service.eraseEverything(root);
}
}
}
var importedCount = 0;
for (final section in tree.sections.entries) {
if (section.value.isEmpty) continue;
final result = await _service.insertTree(
section.key,
section.value.map(toPigeonImportNode).toList(),
);
importedCount += result.insertedItemCount;
if (result.failedNodeCount > 0) {
logger.e(
'Failed to import ${result.failedNodeCount} top-level nodes into ${section.key}',
);
}
}
return importedCount;
}
}
/// Converts a parsed node into the Pigeon transport type.
///
/// Timestamps become milliseconds since epoch, with 0 standing in for "the file
/// did not say", which is what Places expects for an unknown timestamp.
BookmarkImportNode toPigeonImportNode(ImportBookmarkNode node) {
return switch (node) {
final ImportBookmarkFolder folder => BookmarkImportNode(
type: BookmarkNodeType.folder,
title: folder.title,
url: null,
dateAdded: _toMillis(folder.dateAdded),
lastModified: _toMillis(folder.lastModified),
children: folder.children.map(toPigeonImportNode).toList(),
),
final ImportBookmarkItem item => BookmarkImportNode(
type: BookmarkNodeType.item,
title: item.title,
url: item.url.toString(),
dateAdded: _toMillis(item.dateAdded),
lastModified: _toMillis(item.lastModified),
children: const [],
),
final ImportBookmarkSeparator separator => BookmarkImportNode(
type: BookmarkNodeType.separator,
title: null,
url: null,
dateAdded: _toMillis(separator.dateAdded),
lastModified: _toMillis(separator.lastModified),
children: const [],
),
};
}
int _toMillis(DateTime? value) => value?.millisecondsSinceEpoch ?? 0;
@@ -23,8 +23,28 @@
import 'dart:convert';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_importer.dart';
import 'package:weblibre/utils/uri_input_parser.dart';
/// Parses a Firefox JSON bookmark backup into an [ImportBookmarkTree].
///
/// Pure and free of platform channels, so it is safe to run inside an isolate;
/// see `bookmark_import_isolate.dart`.
///
/// Only top-level nodes that identify themselves as a Places root are imported,
/// each into its matching root. `place:` query URLs have their folder ids
/// rewritten to guids, and the tags folder is ignored.
ImportBookmarkTree parseBookmarkJson(String jsonString) {
final data = jsonDecode(jsonString);
if (data is! Map<String, dynamic>) {
throw const FormatException('Invalid JSON format');
}
return const _BookmarkJsonParser().parse(data);
}
class BookmarkJSONUtils {
final GeckoBookmarksService _service;
@@ -33,18 +53,10 @@ class BookmarkJSONUtils {
/// Import bookmarks from JSON string
Future<int> importFromJSON(String jsonString, {bool replace = false}) async {
try {
final data = jsonDecode(jsonString);
if (data is! Map<String, dynamic>) {
throw Exception('Invalid JSON format');
}
final children = data['children'] as List?;
if (children == null || children.isEmpty) {
return 0;
}
return await _import(data, replace: replace);
final tree = parseBookmarkJson(jsonString);
return await BookmarkTreeImporter(
_service,
).import(tree, replace: replace);
} catch (ex) {
logger.e('Failed to import bookmarks: $ex');
rethrow;
@@ -63,206 +75,6 @@ class BookmarkJSONUtils {
return _nodeToJson(tree, isRoot: true);
}
/// Import implementation
Future<int> _import(
Map<String, dynamic> rootNode, {
required bool replace,
}) async {
final nodes =
(rootNode['children'] as List?)
?.whereType<Map<String, dynamic>>()
.where(
(node) =>
node['root'] != 'tagsFolder' &&
node['guid'] != 'tags________',
)
.toList() ??
[];
if (nodes.isEmpty) {
return 0;
}
// If replacing, erase existing bookmarks first
if (replace) {
// Delete bookmarks from each root folder (except root itself to avoid errors)
for (final root in BookmarkRoot.values) {
if (root != BookmarkRoot.root) {
await _service.eraseEverything(root);
}
}
}
final folderIdToGuidMap = <String, String>{};
// Translate tree types and build folder map
for (final node in nodes) {
if (node['children'] == null || (node['children'] as List).isEmpty) {
continue;
}
final folders = _translateTreeTypes(node);
folderIdToGuidMap.addAll(folders);
}
int bookmarkCount = 0;
// Insert nodes
for (final node in nodes) {
if (node['children'] == null || (node['children'] as List).isEmpty) {
continue;
}
final guid = node['guid'] as String?;
if (guid == null || !bookmarkRootIds.contains(guid)) {
continue;
}
_fixupSearchQueries(node, folderIdToGuidMap);
// Insert the tree recursively
bookmarkCount += await _insertTree(node, folderIdToGuidMap);
}
return bookmarkCount;
}
/// Recursively insert bookmark tree
Future<int> _insertTree(
Map<String, dynamic> node,
Map<String, String> folderIdToGuidMap,
) async {
int count = 0;
final children = node['children'] as List?;
if (children == null || children.isEmpty) {
return 0;
}
final parentGuid = node['guid'] as String;
for (int i = 0; i < children.length; i++) {
final child = children[i] as Map<String, dynamic>;
final type = _getNodeType(child);
if (type == BookmarkNodeType.item) {
final url = _getNodeUrl(child);
final title = child['title'] as String? ?? '';
if (url != null && url.isNotEmpty) {
try {
// Validate URL before inserting
final uri = Uri.tryParse(url);
if (uri != null && uri.hasScheme) {
await _service.addItem(parentGuid, uri, title, i);
count++;
} else {
final parsed = Uri.tryParse(url);
final redacted = parsed != null
? redactUriCredentials(parsed)
: url;
logger.w('Skipping invalid URL: $redacted');
}
} catch (e) {
logger.e('Failed to import bookmark "$title": $e');
}
}
} else if (type == BookmarkNodeType.folder) {
final title = child['title'] as String? ?? '';
try {
final newGuid = await _service.addFolder(parentGuid, title, i);
child['guid'] = newGuid;
// Recursively insert children
count += await _insertTree(child, folderIdToGuidMap);
} catch (e) {
logger.e('Failed to import folder "$title": $e');
}
}
// Note: Separators are not supported by the Android API
}
return count;
}
/// Translate tree types from JSON format to internal format
Map<String, String> _translateTreeTypes(Map<String, dynamic> node) {
final folderIdToGuidMap = <String, String>{};
_normalizeNodeUrl(node);
final type = node['type'];
if (type == 'text/x-moz-place-container') {
node['type'] = BookmarkNodeType.folder.index;
final id = node['id']?.toString();
final guid = node['guid'] as String?;
if (id != null && guid != null) {
folderIdToGuidMap[id] = guid;
}
} else if (type == 'text/x-moz-place') {
node['type'] = BookmarkNodeType.item.index;
} else if (type == 'text/x-moz-place-separator') {
node['type'] = BookmarkNodeType.separator.index;
node.remove('title');
}
final children = node['children'] as List?;
if (children != null) {
for (final child in children) {
if (child is Map<String, dynamic>) {
folderIdToGuidMap.addAll(_translateTreeTypes(child));
}
}
}
return folderIdToGuidMap;
}
/// Fix up search queries with folder mappings
void _fixupSearchQueries(
Map<String, dynamic> node,
Map<String, String> folderIdToGuidMap,
) {
final url = _getNodeUrl(node);
if (url != null && url.startsWith('place:')) {
node['url'] = _fixupQuery(url, folderIdToGuidMap);
}
final children = node['children'] as List?;
if (children != null) {
for (final child in children) {
if (child is Map<String, dynamic>) {
_fixupSearchQueries(child, folderIdToGuidMap);
}
}
}
}
/// Replace folder IDs with GUIDs in place: URIs
String _fixupQuery(String queryURL, Map<String, String> folderIdToGuidMap) {
final regex = RegExp(r'folder=([A-Za-z0-9_]+)');
bool invalid = false;
final result = queryURL.replaceAllMapped(regex, (match) {
final folderId = match.group(1)!;
final guid = folderIdToGuidMap[folderId];
if (guid == null) {
invalid = true;
return 'invalidOldParentId=$folderId';
}
return 'parent=$guid';
});
if (invalid) {
return '$result&excludeItems=1';
}
return result;
}
/// Convert BookmarkNode to JSON (for export)
Map<String, dynamic>? _nodeToJson(BookmarkNode node, {bool isRoot = false}) {
// Skip invalid bookmarks
@@ -344,20 +156,188 @@ class BookmarkJSONUtils {
if (guid == BookmarkRoot.mobile.id) return 'mobileFolder';
return null;
}
}
/// Running totals collected while converting a JSON backup.
class _Counters {
int bookmarks = 0;
int folders = 0;
int separators = 0;
int skippedUrls = 0;
}
class _BookmarkJsonParser {
const _BookmarkJsonParser();
ImportBookmarkTree parse(Map<String, dynamic> data) {
final nodes =
(data['children'] as List?)
?.whereType<Map<String, dynamic>>()
.where(
(node) =>
node['root'] != 'tagsFolder' &&
node['guid'] != 'tags________',
)
.toList() ??
const [];
// `place:` query URLs reference folders by numeric id, which is meaningless
// once imported. Collect every id -> guid pair up front, including from
// sections that are not themselves imported, so the rewrite below can
// resolve references that point across roots.
final folderIdToGuid = <String, String>{};
for (final node in nodes) {
if (_childrenOf(node).isEmpty) continue;
_collectFolderGuids(node, folderIdToGuid);
}
final counters = _Counters();
final sections = <String, List<ImportBookmarkNode>>{};
for (final node in nodes) {
final children = _childrenOf(node);
if (children.isEmpty) continue;
// Anything that does not name a Places root has nowhere to go: a backup
// always describes its roots explicitly.
final guid = node['guid'] as String?;
if (guid == null || !bookmarkRootIds.contains(guid)) continue;
final converted = <ImportBookmarkNode>[];
for (final child in children) {
final node = _convert(child, folderIdToGuid, counters);
if (node != null) converted.add(node);
}
if (converted.isEmpty) continue;
sections
.putIfAbsent(guid, () => <ImportBookmarkNode>[])
.addAll(converted);
}
return ImportBookmarkTree(
sections: sections,
stats: ImportBookmarkStats(
bookmarkCount: counters.bookmarks,
folderCount: counters.folders,
separatorCount: counters.separators,
skippedUrlCount: counters.skippedUrls,
),
);
}
ImportBookmarkNode? _convert(
Map<String, dynamic> node,
Map<String, String> folderIdToGuid,
_Counters counters,
) {
final dateAdded = _parseTimestamp(node['dateAdded']);
final lastModified = _parseTimestamp(node['lastModified']);
switch (_getNodeType(node)) {
case BookmarkNodeType.folder:
final children = <ImportBookmarkNode>[];
for (final child in _childrenOf(node)) {
final converted = _convert(child, folderIdToGuid, counters);
if (converted != null) children.add(converted);
}
counters.folders++;
return ImportBookmarkFolder(
title: node['title'] as String? ?? '',
children: children,
dateAdded: dateAdded,
lastModified: lastModified,
);
case BookmarkNodeType.item:
var url = _getNodeUrl(node);
if (url == null || url.isEmpty) {
counters.skippedUrls++;
return null;
}
if (url.startsWith('place:')) {
url = _fixupQuery(url, folderIdToGuid);
}
final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme) {
counters.skippedUrls++;
logger.w(
'Skipping invalid URL: ${uri != null ? redactUriCredentials(uri) : url}',
);
return null;
}
counters.bookmarks++;
return ImportBookmarkItem(
url: uri,
title: node['title'] as String? ?? '',
dateAdded: dateAdded,
lastModified: lastModified,
);
case BookmarkNodeType.separator:
counters.separators++;
return ImportBookmarkSeparator(
dateAdded: dateAdded,
lastModified: lastModified,
);
}
}
void _collectFolderGuids(
Map<String, dynamic> node,
Map<String, String> folderIdToGuid,
) {
if (node['type'] == 'text/x-moz-place-container') {
final id = node['id']?.toString();
final guid = node['guid'] as String?;
if (id != null && guid != null) {
folderIdToGuid[id] = guid;
}
}
for (final child in _childrenOf(node)) {
_collectFolderGuids(child, folderIdToGuid);
}
}
List<Map<String, dynamic>> _childrenOf(Map<String, dynamic> node) {
return (node['children'] as List?)
?.whereType<Map<String, dynamic>>()
.toList() ??
const [];
}
/// Replace folder IDs with GUIDs in place: URIs
String _fixupQuery(String queryURL, Map<String, String> folderIdToGuidMap) {
final regex = RegExp(r'folder=([A-Za-z0-9_]+)');
bool invalid = false;
final result = queryURL.replaceAllMapped(regex, (match) {
final folderId = match.group(1)!;
final guid = folderIdToGuidMap[folderId];
if (guid == null) {
invalid = true;
return 'invalidOldParentId=$folderId';
}
return 'parent=$guid';
});
if (invalid) {
return '$result&excludeItems=1';
}
return result;
}
/// Get URL from node (accepts both 'url' and 'uri')
String? _getNodeUrl(Map<String, dynamic> node) {
return node['url'] as String? ?? node['uri'] as String?;
}
/// Normalize 'uri' to 'url' during import
void _normalizeNodeUrl(Map<String, dynamic> node) {
if (node.containsKey('uri')) {
node['url'] = node['uri'];
node.remove('uri');
}
}
/// Get node type from JSON
BookmarkNodeType _getNodeType(Map<String, dynamic> node) {
final type = node['type'];
@@ -373,4 +353,21 @@ class BookmarkJSONUtils {
return BookmarkNodeType.separator;
}
}
/// Reads a backup timestamp, which may be in either unit.
///
/// Firefox writes PRTime (microseconds), while WebLibre's own JSON export
/// writes Places' native milliseconds. Any microsecond value for a date after
/// ~1973 exceeds [_microsecondThreshold], whereas a millisecond value would
/// have to be a date past the year 5138 to reach it.
static const _microsecondThreshold = 100000000000000;
DateTime? _parseTimestamp(Object? value) {
final raw = value is int ? value : int.tryParse(value?.toString() ?? '');
if (raw == null || raw <= 0) return null;
return DateTime.fromMillisecondsSinceEpoch(
raw > _microsecondThreshold ? raw ~/ 1000 : raw,
);
}
}
@@ -17,7 +17,6 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
@@ -31,8 +30,8 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/font_size_constants.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart';
@@ -783,18 +782,16 @@ class _BookmarkToolbarButton extends HookConsumerWidget {
final tabUrl = scope.tabState?.url;
final bookmarkable = tabUrl != null && !scope.isPreview;
final existingGuids = ref
.watch(
bookmarksRepositoryProvider.select(
(async) => EquatableValue(
bookmarkable
? bookmarkGuidsForUrl(async.value, tabUrl)
: const <String>[],
),
),
)
.value;
// Answered by a storage lookup keyed on the URL, so this does not depend on
// the whole bookmark tree being resident in memory.
final bookmarkLookup = ref.watch(
bookmarkGuidsForUrlProvider(bookmarkable ? tabUrl : null),
);
final existingGuids = bookmarkLookup.value ?? const <String>[];
// Until the lookup settles an existing bookmark is indistinguishable from
// none, and assuming none would let a quick tap add a second copy.
final canToggleBookmark = bookmarkable && bookmarkLookup.hasValue;
final isBookmarked = existingGuids.isNotEmpty;
return MenuAnchor(
@@ -819,7 +816,7 @@ class _BookmarkToolbarButton extends HookConsumerWidget {
else
MenuItemButton(
leadingIcon: const Icon(MdiIcons.bookmarkPlus),
onPressed: !bookmarkable
onPressed: !canToggleBookmark
? null
: () async {
await ref
@@ -869,25 +866,23 @@ class _BookmarkToggleToolbarButton extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final tabUrl = scope.tabState?.url;
final bookmarkable = tabUrl != null && !scope.isPreview;
final existingGuids = ref
.watch(
bookmarksRepositoryProvider.select(
(async) => EquatableValue(
bookmarkable
? bookmarkGuidsForUrl(async.value, tabUrl)
: const <String>[],
),
),
)
.value;
// Answered by a storage lookup keyed on the URL, so this does not depend on
// the whole bookmark tree being resident in memory.
final bookmarkLookup = ref.watch(
bookmarkGuidsForUrlProvider(bookmarkable ? tabUrl : null),
);
final existingGuids = bookmarkLookup.value ?? const <String>[];
// Until the lookup settles an existing bookmark is indistinguishable from
// none, and assuming none would let a quick tap add a second copy.
final canToggleBookmark = bookmarkable && bookmarkLookup.hasValue;
final isBookmarked = existingGuids.isNotEmpty;
return IconButton(
tooltip: isBookmarked ? 'Remove bookmark' : 'Add bookmark',
onPressed: scope.isPreview
? () {}
: !bookmarkable
: !canToggleBookmark
? null
: () async {
if (isBookmarked) {
@@ -628,7 +628,8 @@ class RailAppBarTitleView extends StatelessWidget {
: MdiIcons.shieldAlert,
size: 10,
color:
siteSettingsBadgeState == SiteSettingsBadgeState.improved
siteSettingsBadgeState ==
SiteSettingsBadgeState.improved
? Colors.green
: appColors.warningAmber,
),
@@ -317,7 +317,9 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
if (entries.isEmpty) {
// Hold the 48px slot; the bar visibility is decided upstream by
// quickTabSwitcherRowCountProvider.
return isVertical ? const SizedBox(width: 48) : const SizedBox(height: 48);
return isVertical
? const SizedBox(width: 48)
: const SizedBox(height: 48);
}
return NotificationListener<UserScrollNotification>(
@@ -522,19 +524,54 @@ class _AccordionHeaderChip extends StatelessWidget {
// count badge inside the label instead, dropping the avatar slot.
return wrapLongPress(
FilterChip(
labelPadding: EdgeInsets.zero,
label: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (iconAvatar != null) iconAvatar,
if (countBadge != null) ...[
if (iconAvatar != null) const SizedBox(height: 4),
// Multi-digit counts can exceed the narrow rail's fixed 48px chip
// width; scale the badge down to fit instead of overflowing.
FittedBox(fit: BoxFit.scaleDown, child: countBadge),
labelPadding: EdgeInsets.zero,
label: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (iconAvatar != null) iconAvatar,
if (countBadge != null) ...[
if (iconAvatar != null) const SizedBox(height: 4),
// Multi-digit counts can exceed the narrow rail's fixed 48px chip
// width; scale the badge down to fit instead of overflowing.
FittedBox(fit: BoxFit.scaleDown, child: countBadge),
],
],
],
),
color: WidgetStatePropertyAll(fill),
selected: false,
showCheckmark: false,
onSelected: (value) {
if (value) {
onSelected();
}
},
side: side,
shape: shape,
),
);
}
return wrapLongPress(
FilterChip(
avatar: iconAvatar,
label: container != null
? buildContainerChipLabel(
context,
container,
true,
trailing: countBadge,
)
: SizedBox(
height: 20,
child: Center(
child:
countBadge ??
DefaultTextStyle.merge(
style: TextStyle(color: nullForeground),
child: const SizedBox.shrink(),
),
),
),
color: WidgetStatePropertyAll(fill),
selected: false,
showCheckmark: false,
@@ -545,41 +582,6 @@ class _AccordionHeaderChip extends StatelessWidget {
},
side: side,
shape: shape,
),
);
}
return wrapLongPress(
FilterChip(
avatar: iconAvatar,
label: container != null
? buildContainerChipLabel(
context,
container,
true,
trailing: countBadge,
)
: SizedBox(
height: 20,
child: Center(
child:
countBadge ??
DefaultTextStyle.merge(
style: TextStyle(color: nullForeground),
child: const SizedBox.shrink(),
),
),
),
color: WidgetStatePropertyAll(fill),
selected: false,
showCheckmark: false,
onSelected: (value) {
if (value) {
onSelected();
}
},
side: side,
shape: shape,
),
);
}
@@ -82,12 +82,11 @@ class OpenInNewTab extends HookConsumerWidget {
// Alternative tab types the user can explicitly choose, excluding the type
// that the main tile action already opens (the current tab's type).
final alternativeTypes =
<TabType>[
TabType.regular,
TabType.private,
if (settings.showIsolatedTabUi) TabType.isolated,
]..remove(currentTabMode.toTabType());
final alternativeTypes = <TabType>[
TabType.regular,
TabType.private,
if (settings.showIsolatedTabUi) TabType.isolated,
]..remove(currentTabMode.toTabType());
return ListTile(
leading: const Icon(MdiIcons.tabPlus),
@@ -90,7 +90,9 @@ class VisitContainerRecorder extends _$VisitContainerRecorder {
// permanently. A genuine miss (deleted container) leaves the cache
// non-empty, so this fallback does not fire repeatedly in steady state.
if (containerId == null && contextIdToContainerId.isEmpty) {
applyContainers(await ref.read(watchContainersWithCountProvider.future));
applyContainers(
await ref.read(watchContainersWithCountProvider.future),
);
containerId = contextIdToContainerId[contextId];
}
@@ -75,9 +75,9 @@ class VisitContainerDao extends DatabaseAccessor<TabDatabase>
start,
math.min(start + chunkSize, canonicalList.length),
);
final rows = await (db.select(db.visitContainer)
..where((t) => t.urlCanonical.isIn(chunk)))
.get();
final rows = await (db.select(
db.visitContainer,
)..where((t) => t.urlCanonical.isIn(chunk))).get();
results.addAll(rows);
}
return results;
@@ -103,9 +103,9 @@ class VisitContainerDao extends DatabaseAccessor<TabDatabase>
/// Container deletion dissolves relations automatically via ON DELETE CASCADE
/// and does not go through here.
Future<int> deleteForContainer(String containerId) {
return (db.delete(db.visitContainer)
..where((t) => t.containerId.equals(containerId)))
.go();
return (db.delete(
db.visitContainer,
)..where((t) => t.containerId.equals(containerId))).go();
}
/// Remove every relation row, all containers. Used when the user clears all
@@ -62,7 +62,8 @@ Future<void> _removeAppLinkOverrides(
) {
if (!ids.any(current.appLinkContextOverrides.containsKey)) return current;
return current.copyWith.appLinkContextOverrides(
{...current.appLinkContextOverrides}..removeWhere((key, _) => ids.contains(key)),
{...current.appLinkContextOverrides}
..removeWhere((key, _) => ids.contains(key)),
);
});
}
@@ -34,7 +34,6 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/font_size_constants.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/translation_bottom_sheet.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
@@ -229,10 +228,8 @@ class GestureControlService extends _$GestureControlService {
final bookmarkUrl =
ref.read(sandboxSourceUriForTabProvider(tabId: tabId)) ?? tabState.url;
final bookmarks = ref.read(bookmarksRepositoryProvider).value;
final existingGuids = bookmarkGuidsForUrl(bookmarks, bookmarkUrl);
final repository = ref.read(bookmarksRepositoryProvider.notifier);
final existingGuids = await repository.bookmarkGuidsForUrl(bookmarkUrl);
if (existingGuids.isNotEmpty) {
for (final guid in existingGuids) {
await repository.delete(guid);
@@ -66,7 +66,7 @@ final class GestureControlServiceProvider
}
String _$gestureControlServiceHash() =>
r'12bd852a5b90b67bee4a94e7bd55fccc53c11bd4';
r'3741c0a3d9cb6726c044bab1efd4ce80208f21d8';
/// Bridges gesture settings and recognized-gesture events to app actions.
///
@@ -781,8 +781,8 @@ class _AppLinksModeSection extends HookConsumerWidget {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(current) =>
current.copyWith.appLinkMarketplaceFallback(value),
(current) => current.copyWith
.appLinkMarketplaceFallback(value),
);
},
),
@@ -845,9 +845,9 @@ class _AppLinkRulesSubsection extends ConsumerWidget {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(current) => current.copyWith.appLinkRules({
...current.appLinkRules,
}..remove(key)),
(current) => current.copyWith.appLinkRules(
{...current.appLinkRules}..remove(key),
),
);
},
),
@@ -19,13 +19,12 @@
*/
import 'dart:math';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
// What words does the wanderer whisper?
@@ -173,18 +172,16 @@ class SmallWebBottomBar extends HookConsumerWidget {
final tabUrl = currentTabUrl;
final bookmarkable = tabUrl != null;
final existingGuids = ref
.watch(
bookmarksRepositoryProvider.select(
(async) => EquatableValue(
bookmarkable
? bookmarkGuidsForUrl(async.value, tabUrl)
: const <String>[],
),
),
)
.value;
// Answered by a storage lookup keyed on the URL, so this does not depend on
// the whole bookmark tree being resident in memory.
final bookmarkLookup = ref.watch(
bookmarkGuidsForUrlProvider(bookmarkable ? tabUrl : null),
);
final existingGuids = bookmarkLookup.value ?? const <String>[];
// Until the lookup settles an existing bookmark is indistinguishable from
// none, and assuming none would let a quick tap add a second copy.
final canToggleBookmark = bookmarkable && bookmarkLookup.hasValue;
final isBookmarked = existingGuids.isNotEmpty;
return SizedBox(
@@ -224,7 +221,7 @@ class SmallWebBottomBar extends HookConsumerWidget {
IconButton(
icon: Icon(isBookmarked ? Icons.bookmark : Icons.bookmark_border),
tooltip: isBookmarked ? 'Remove bookmark' : 'Add bookmark',
onPressed: !bookmarkable
onPressed: !canToggleBookmark
? null
: () async {
if (isBookmarked) {