feat: tag bookmarks and sites, and browse the library by tag

This commit is contained in:
randogoth 2026-10-11 13:32:00 +03:00
parent 5cd053c8a9
commit b0b86ef5b1
13 changed files with 445 additions and 21 deletions

View file

@ -218,7 +218,7 @@ class MewsDatabase extends _$MewsDatabase {
/// Bookmarks in the order they were added, newest first. [query] narrows by
/// title or address.
Future<List<Bookmark>> bookmarksByDate({String? query}) async {
Future<List<Bookmark>> bookmarksByDate({String? query, String? tag}) async {
final statement = (select(bookmarks)
..orderBy([
(r) => OrderingTerm.desc(r.createdAt),
@ -227,6 +227,9 @@ class MewsDatabase extends _$MewsDatabase {
]));
final filter = _search([bookmarks.url, bookmarks.title], query);
if (filter != null) statement.where((_) => filter);
if (tag != null && tag.isNotEmpty) {
statement.where((r) => r.url.isInQuery(_taggedUrls(TagKind.bookmark, tag)));
}
return statement.get();
}
@ -261,7 +264,7 @@ class MewsDatabase extends _$MewsDatabase {
});
/// Subscribed sites, newest first. [query] narrows by title or address.
Future<List<Subscription>> allSubscriptions({String? query}) async {
Future<List<Subscription>> allSubscriptions({String? query, String? tag}) async {
final statement = (select(subscriptions)
..orderBy([
(r) => OrderingTerm.desc(r.createdAt),
@ -269,6 +272,10 @@ class MewsDatabase extends _$MewsDatabase {
]));
final filter = _search([subscriptions.url, subscriptions.title], query);
if (filter != null) statement.where((_) => filter);
if (tag != null && tag.isNotEmpty) {
statement
.where((r) => r.url.isInQuery(_taggedUrls(TagKind.subscription, tag)));
}
return statement.get();
}
@ -405,6 +412,75 @@ class MewsDatabase extends _$MewsDatabase {
// Tags ---------------------------------------------------------------------
/// The addresses of one kind carrying [tag], for filtering a library page.
JoinedSelectStatement<ItemTags, ItemTag> _taggedUrls(
TagKind kind, String tag) {
return selectOnly(itemTags)
..addColumns([itemTags.url])
..where(itemTags.kind.equalsValue(kind) & itemTags.tag.equals(tag));
}
/// Every tag in use with how many bookmarks and sites carry it. A tag exists
/// only while something carries it, so this is the whole list of tags.
Future<Map<String, TagUse>> tagCounts() async {
final total = itemTags.url.count();
final query = selectOnly(itemTags)
..addColumns([itemTags.tag, itemTags.kind, total])
..groupBy([itemTags.tag, itemTags.kind])
..orderBy([OrderingTerm.asc(itemTags.tag)]);
final counts = <String, TagUse>{};
for (final row in await query.get()) {
final tag = row.read(itemTags.tag)!;
final kind = itemTags.kind.converter.fromSql(row.read(itemTags.kind)!);
final count = row.read(total) ?? 0;
final held = counts[tag] ?? const TagUse(bookmarks: 0, sites: 0);
counts[tag] = switch (kind) {
TagKind.bookmark => TagUse(bookmarks: count, sites: held.sites),
TagKind.subscription =>
TagUse(bookmarks: held.bookmarks, sites: count),
};
}
return counts;
}
/// The tags on several items at once, so a list of rows costs one query.
Future<Map<String, List<String>>> tagsOfMany(
TagKind kind, List<String> urls) async {
if (urls.isEmpty) return const {};
final rows = await (select(itemTags)
..where((r) => r.kind.equalsValue(kind) & r.url.isIn(urls))
..orderBy([
(r) => OrderingTerm.asc(r.createdAt),
(r) => OrderingTerm.asc(r.tag),
]))
.get();
final tags = <String, List<String>>{};
for (final row in rows) {
(tags[row.url] ??= []).add(row.tag);
}
return tags;
}
/// Removes a tag from everything carrying it.
Future<void> deleteTag(String tag) =>
(delete(itemTags)..where((r) => r.tag.equals(tag))).go();
/// What the reader calls one bookmark or site, for titling its tags page.
Future<String?> itemTitle(TagKind kind, String url) async {
switch (kind) {
case TagKind.bookmark:
final row = await (select(bookmarks)..where((r) => r.url.equals(url)))
.getSingleOrNull();
return row?.title;
case TagKind.subscription:
final row =
await (select(subscriptions)..where((r) => r.url.equals(url)))
.getSingleOrNull();
return row?.title;
}
}
/// The tags on one item, in the order they were added.
Future<List<String>> tagsOf(TagKind kind, String url) async {
final query = (select(itemTags)
@ -481,6 +557,16 @@ enum EntrySort {
};
}
/// How widely one tag is used.
class TagUse {
const TagUse({required this.bookmarks, required this.sites});
final int bookmarks;
final int sites;
int get total => bookmarks + sites;
}
/// One page the reader visited, collapsed to its latest visit.
class VisitedPage {
const VisitedPage({