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