From b0b86ef5b17ed77520604773bacaad38a605b84d Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 11 Oct 2026 13:32:00 +0300 Subject: [PATCH] feat: tag bookmarks and sites, and browse the library by tag --- SPEC.md | 1 + app/lib/application/internal_actions.dart | 29 ++++ app/lib/application/internal_pages.dart | 2 + app/lib/application/pages/bookmarks_page.dart | 34 +++- app/lib/application/pages/history_page.dart | 5 +- app/lib/application/pages/hub_page.dart | 9 + app/lib/application/pages/mews_urls.dart | 1 + app/lib/application/pages/page_blocks.dart | 20 ++- .../application/pages/subscriptions_page.dart | 34 +++- app/lib/application/pages/tags_page.dart | 157 ++++++++++++++++++ app/lib/database/database.dart | 90 +++++++++- app/locales/en-US/main.ftl | 24 +++ app/test/app_widget_test.dart | 60 ++++++- 13 files changed, 445 insertions(+), 21 deletions(-) create mode 100644 app/lib/application/pages/tags_page.dart diff --git a/SPEC.md b/SPEC.md index 1c48013..dc1280c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -68,6 +68,7 @@ The v1 client presents one reader screen: - The client's own pages live under the `mews:` scheme. `mews:hub` names them and says what each one holds; `mews:posts`, `mews:bookmarks`, `mews:subscriptions`, `mews:history` and `mews:settings` each carry a strip linking to the others. Bare `mews:` is the hub, and the hub is where a session with no history to return to starts. Each page is built as an ordinary Mews document and rendered by the same renderer as a fetched page, so it joins the history stack and the swipe transitions, and none of them is recorded in history or offered for subscription. - `mews:posts` is every post from every subscribed site in one list. The reader can order it by date, by site, or unread first, and limit it to unread posts; those choices ride in the address as a query string, so going back undoes one and a swipe preview shows the page the reader will land on. Unread posts are shown bold, and opening a post marks it read. - Subscribed sites are checked once in the background at launch, and whenever the reader asks. Sites are checked one after another rather than at once, and a site that cannot be reached keeps the posts already held and is still recorded as checked, so one site being down never stops the rest. +- The reader can tag bookmarks and sites. A tag exists only while something carries it, so adding a tag and making one are the same act; `mews:tags` lists every tag in use and what carries it, and following a tag narrows the list it belongs to. There is no renaming and no empty tag. - Each library page carries a search box that narrows it by title or address, and each row carries its own actions: remove a bookmark, leave a site or check it now, forget a page. History lists one row per address, headed by the most recent visit, rather than repeating a page once per visit. Clearing history takes a second step on a page the reader can simply leave, since it cannot be undone. - A change is a link too: following `mews:do/` applies it and rerenders the page in place without adding a history entry. Only a link followed from one of the client's own pages is honored, so a fetched page cannot clear history or change a setting on the reader's behalf (spec 8.1). A form on one of these pages is routed the same way rather than sent to the network. - Fonts are bundled, and v1 ships Atkinson Hyperlegible. Adding a font is one TTF in `app/assets/fonts/`, one `fonts` entry in `app/pubspec.yaml`, one option on the settings page, and one label in `app/locales/en-US/main.ftl`. diff --git a/app/lib/application/internal_actions.dart b/app/lib/application/internal_actions.dart index df0f0b5..539b15d 100644 --- a/app/lib/application/internal_actions.dart +++ b/app/lib/application/internal_actions.dart @@ -45,6 +45,9 @@ class InternalActions { 'unsubscribe' => _withUrl(args, _subscriptions.remove), 'forget-page' => _withUrl(args, _db.forgetPage), 'clear-history' => _clearHistory(), + 'add-tag' => _changeTag(args, add: true), + 'remove-tag' => _changeTag(args, add: false), + 'delete-tag' => _deleteTag(args['tag']), _ => false, }; } @@ -64,6 +67,32 @@ class InternalActions { return true; } + /// Adds or removes one tag. An empty name is refused, so submitting an + /// empty box does nothing. + Future _changeTag(Map args, {required bool add}) async { + final kind = switch (args['kind']) { + 'bookmark' => TagKind.bookmark, + 'subscription' => TagKind.subscription, + _ => null, + }; + final url = args['url']; + final tag = args['tag']?.trim(); + if (kind == null || url == null || url.isEmpty) return false; + if (tag == null || tag.isEmpty) return false; + if (add) { + await _db.addTag(kind, url, tag); + } else { + await _db.removeTag(kind, url, tag); + } + return true; + } + + Future _deleteTag(String? tag) async { + if (tag == null || tag.isEmpty) return false; + await _db.deleteTag(tag); + return true; + } + Future _clearHistory() async { await _db.clearHistory(); return true; diff --git a/app/lib/application/internal_pages.dart b/app/lib/application/internal_pages.dart index 3381b35..1f85f49 100644 --- a/app/lib/application/internal_pages.dart +++ b/app/lib/application/internal_pages.dart @@ -10,6 +10,7 @@ import 'pages/hub_page.dart'; import 'pages/mews_urls.dart'; import 'pages/posts_page.dart'; import 'pages/settings_page.dart'; +import 'pages/tags_page.dart'; import 'pages/subscriptions_page.dart'; /// Builds the client's own pages as real [MewsPage]s: they render like any @@ -48,6 +49,7 @@ class InternalPages { 'bookmarks' => buildBookmarksPage(uri, db: _db, strings: _strings), 'subscriptions' => buildSubscriptionsPage(uri, db: _db, strings: _strings), 'history' => buildHistoryPage(uri, db: _db, strings: _strings), + 'tags' => buildTagsPage(uri, db: _db, strings: _strings), 'settings' => buildSettingsPage(uri, settings: _settings, strings: _strings), _ => Future.value(null), diff --git a/app/lib/application/pages/bookmarks_page.dart b/app/lib/application/pages/bookmarks_page.dart index 19d7c44..8b40af4 100644 --- a/app/lib/application/pages/bookmarks_page.dart +++ b/app/lib/application/pages/bookmarks_page.dart @@ -3,6 +3,7 @@ import '../../l10n/strings.dart'; import '../../mews/document.dart'; import 'mews_urls.dart'; import 'page_blocks.dart'; +import 'tags_page.dart'; Future buildBookmarksPage( Uri url, { @@ -10,7 +11,10 @@ Future buildBookmarksPage( required Strings strings, }) async { final query = url.queryParameters['q'] ?? ''; - final bookmarks = await db.bookmarksByDate(query: query); + final tag = url.queryParameters['tag']; + final bookmarks = await db.bookmarksByDate(query: query, tag: tag); + final tags = await db.tagsOfMany( + TagKind.bookmark, bookmarks.map((b) => b.url).toList()); final title = strings.t('bookmarks-title'); return internalPage( @@ -18,16 +22,30 @@ Future buildBookmarksPage( title: title, body: [ ...chrome(title: title, current: url, strings: strings), - searchForm( + textForm( action: MewsUrls.bookmarks, label: strings.t('bookmarks-search-label'), submitLabel: strings.t('action-search'), - query: query, + field: 'q', + value: query, ), - if (query.isNotEmpty) - ParagraphBlock([ + ParagraphBlock([ + if (query.isNotEmpty) ...[ MewsLink(MewsUrls.page(url, {'q': null}), [MewsText(strings.t('action-clear-search'))]), + const MewsText(actionSeparator), + ], + if (tag != null && tag.isNotEmpty) ...[ + MewsLink(MewsUrls.page(url, {'tag': null}), + [MewsText(strings.t('bookmarks-show-all'))]), + const MewsText(actionSeparator), + ], + MewsLink(MewsUrls.tags, [MewsText(strings.t('nav-tags'))]), + ]), + if (tag != null && tag.isNotEmpty) + ParagraphBlock([ + MewsEmphasis(EmphasisStyle.emphasis, + [MewsText(strings.t('bookmarks-tagged', {'tag': tag}))]), ]), if (bookmarks.isEmpty) ParagraphBlock([ @@ -41,10 +59,16 @@ Future buildBookmarksPage( href: bookmark.url, label: bookmark.title ?? bookmark.url, date: dayText(bookmark.createdAt), + chips: [ + for (final tag in tags[bookmark.url] ?? const []) + action(MewsUrls.page(url, {'tag': tag, 'q': null}), tag), + ], actions: [ action( MewsUrls.action('remove-bookmark', {'url': bookmark.url}), strings.t('action-remove-bookmark')), + action(tagsOfItemUrl(TagKind.bookmark, bookmark.url), + strings.t('action-edit-tags')), ], ), ]), diff --git a/app/lib/application/pages/history_page.dart b/app/lib/application/pages/history_page.dart index 2f11d48..7d774a3 100644 --- a/app/lib/application/pages/history_page.dart +++ b/app/lib/application/pages/history_page.dart @@ -19,11 +19,12 @@ Future buildHistoryPage( title: title, body: [ ...chrome(title: title, current: url, strings: strings), - searchForm( + textForm( action: MewsUrls.history, label: strings.t('history-search-label'), submitLabel: strings.t('action-search'), - query: query, + field: 'q', + value: query, ), // Clearing history cannot be undone, so it takes a second step rather // than a dialog: another page the reader can simply leave. diff --git a/app/lib/application/pages/hub_page.dart b/app/lib/application/pages/hub_page.dart index a9509e0..846e02f 100644 --- a/app/lib/application/pages/hub_page.dart +++ b/app/lib/application/pages/hub_page.dart @@ -18,6 +18,7 @@ Future buildHubPage( final sites = await db.countSubscriptions(); final visits = await db.countHistory(); final unread = await db.unreadCount(); + final tags = await db.tagCounts(); return internalPage( url: url, @@ -57,6 +58,14 @@ Future buildHubPage( one: 'count-pages-one', many: 'count-pages-many'), ), + itemRow( + href: MewsUrls.tags, + label: strings.t('nav-tags'), + note: countText(strings, tags.length, + none: 'count-tags-none', + one: 'count-tags-one', + many: 'count-tags-many'), + ), itemRow(href: MewsUrls.settings, label: strings.t('nav-settings')), ]), ParagraphBlock([ diff --git a/app/lib/application/pages/mews_urls.dart b/app/lib/application/pages/mews_urls.dart index 357b779..9412c20 100644 --- a/app/lib/application/pages/mews_urls.dart +++ b/app/lib/application/pages/mews_urls.dart @@ -17,6 +17,7 @@ abstract final class MewsUrls { static const bookmarks = 'mews:bookmarks'; static const subscriptions = 'mews:subscriptions'; static const history = 'mews:history'; + static const tags = 'mews:tags'; static const settings = 'mews:settings'; /// Whether [url] belongs to the internal scheme. diff --git a/app/lib/application/pages/page_blocks.dart b/app/lib/application/pages/page_blocks.dart index 9790baa..9c5b896 100644 --- a/app/lib/application/pages/page_blocks.dart +++ b/app/lib/application/pages/page_blocks.dart @@ -68,6 +68,7 @@ MewsListItem itemRow({ String? date, String? note, bool strong = false, + List chips = const [], List actions = const [], }) { final text = date == null ? label : '$date $label'; @@ -83,6 +84,11 @@ MewsListItem itemRow({ const MewsLineBreak(), MewsEmphasis(EmphasisStyle.emphasis, [MewsText(note)]), ], + if (chips.isNotEmpty) ...[ + const MewsLineBreak(), + ..._separated( + [for (final chip in chips) MewsLink(chip.href, [MewsText(chip.label)])]), + ], if (actions.isNotEmpty) ...[ const MewsLineBreak(), ..._separated( @@ -139,18 +145,20 @@ ListBlock choiceRow(List options) { ); } -/// A one-field search box that submits back to its own page, turning what the -/// reader typed into a query string. [keep] carries the parameters the submit -/// must not drop as hidden fields, the way an HTML form does. +/// A one-field form that submits back into the mews scheme, turning what the +/// reader typed into one query parameter named [field]. [keep] carries the +/// parameters the submit must not drop as hidden fields, the way an HTML form +/// does. /// /// Keep this at a fixed position in a page's blocks: the renderer matches form /// state by position, so a block appearing above it would reset what the /// reader typed. -FormBlock searchForm({ +FormBlock textForm({ required String action, + required String field, required String label, required String submitLabel, - required String query, + String value = '', Map keep = const {}, }) { return FormBlock( @@ -158,7 +166,7 @@ FormBlock searchForm({ method: 'get', submitLabel: submitLabel, fields: [ - MewsTextField(name: 'q', label: label, password: false, value: query), + MewsTextField(name: field, label: label, password: false, value: value), for (final kept in keep.entries) MewsHiddenField(name: kept.key, value: kept.value), MewsSubmitField(reset: false, label: submitLabel), diff --git a/app/lib/application/pages/subscriptions_page.dart b/app/lib/application/pages/subscriptions_page.dart index 711a22b..aa7d325 100644 --- a/app/lib/application/pages/subscriptions_page.dart +++ b/app/lib/application/pages/subscriptions_page.dart @@ -3,6 +3,7 @@ import '../../l10n/strings.dart'; import '../../mews/document.dart'; import 'mews_urls.dart'; import 'page_blocks.dart'; +import 'tags_page.dart'; Future buildSubscriptionsPage( Uri url, { @@ -10,7 +11,10 @@ Future buildSubscriptionsPage( required Strings strings, }) async { final query = url.queryParameters['q'] ?? ''; - final sites = await db.allSubscriptions(query: query); + final tag = url.queryParameters['tag']; + final sites = await db.allSubscriptions(query: query, tag: tag); + final tags = await db.tagsOfMany( + TagKind.subscription, sites.map((s) => s.url).toList()); final unread = await db.unreadCounts(); final title = strings.t('subscriptions-title'); @@ -19,16 +23,30 @@ Future buildSubscriptionsPage( title: title, body: [ ...chrome(title: title, current: url, strings: strings), - searchForm( + textForm( action: MewsUrls.subscriptions, label: strings.t('subscriptions-search-label'), submitLabel: strings.t('action-search'), - query: query, + field: 'q', + value: query, ), - if (query.isNotEmpty) - ParagraphBlock([ + ParagraphBlock([ + if (query.isNotEmpty) ...[ MewsLink(MewsUrls.page(url, {'q': null}), [MewsText(strings.t('action-clear-search'))]), + const MewsText(actionSeparator), + ], + if (tag != null && tag.isNotEmpty) ...[ + MewsLink(MewsUrls.page(url, {'tag': null}), + [MewsText(strings.t('subscriptions-show-all'))]), + const MewsText(actionSeparator), + ], + MewsLink(MewsUrls.tags, [MewsText(strings.t('nav-tags'))]), + ]), + if (tag != null && tag.isNotEmpty) + ParagraphBlock([ + MewsEmphasis(EmphasisStyle.emphasis, + [MewsText(strings.t('subscriptions-tagged', {'tag': tag}))]), ]), if (sites.isEmpty) ParagraphBlock([ @@ -43,12 +61,18 @@ Future buildSubscriptionsPage( href: site.url, label: site.title ?? site.url, note: _checkedNote(strings, site, unread[site.url] ?? 0), + chips: [ + for (final tag in tags[site.url] ?? const []) + action(MewsUrls.page(url, {'tag': tag, 'q': null}), tag), + ], actions: [ action( MewsUrls.action('refresh-subscription', {'url': site.url}), strings.t('action-refresh')), action(MewsUrls.action('unsubscribe', {'url': site.url}), strings.t('action-unsubscribe')), + action(tagsOfItemUrl(TagKind.subscription, site.url), + strings.t('action-edit-tags')), ], ), ]), diff --git a/app/lib/application/pages/tags_page.dart b/app/lib/application/pages/tags_page.dart new file mode 100644 index 0000000..bf6b57a --- /dev/null +++ b/app/lib/application/pages/tags_page.dart @@ -0,0 +1,157 @@ +import '../../database/database.dart'; +import '../../l10n/strings.dart'; +import '../../mews/document.dart'; +import 'mews_urls.dart'; +import 'page_blocks.dart'; + +/// Tags, in two shapes. Without an item it lists every tag in use and what +/// carries it; with one it is where the reader puts tags on that item. +/// +/// A tag exists only while something carries it, so adding a tag and creating +/// one are the same act. +Future buildTagsPage( + Uri url, { + required MewsDatabase db, + required Strings strings, +}) async { + final kind = _kindOf(url.queryParameters['kind']); + final item = url.queryParameters['url']; + return kind != null && item != null && item.isNotEmpty + ? _assign(url, db: db, strings: strings, kind: kind, item: item) + : _index(url, db: db, strings: strings); +} + +TagKind? _kindOf(String? name) => switch (name) { + 'bookmark' => TagKind.bookmark, + 'subscription' => TagKind.subscription, + _ => null, +}; + +/// The address of the tags page for one item. +String tagsOfItemUrl(TagKind kind, String url) => + MewsUrls.page(Uri.parse(MewsUrls.tags), {'kind': kind.name, 'url': url}); + +Future _index( + Uri url, { + required MewsDatabase db, + required Strings strings, +}) async { + final counts = await db.tagCounts(); + final title = strings.t('tags-title'); + + return internalPage( + url: url, + title: title, + body: [ + ...chrome(title: title, current: url, strings: strings), + if (counts.isEmpty) + ParagraphBlock([MewsText(strings.t('tags-empty'))]) + else + itemList([ + for (final tag in counts.keys) + MewsListItem([ + ParagraphBlock([ + MewsEmphasis(EmphasisStyle.strong, [MewsText(tag)]), + const MewsLineBreak(), + ..._uses(strings, tag, counts[tag]!), + const MewsLineBreak(), + MewsLink(MewsUrls.action('delete-tag', {'tag': tag}), + [MewsText(strings.t('tags-delete'))]), + ]), + ]), + ]), + ], + ); +} + +/// What carries a tag, each count a link to the list it narrows. +List _uses(Strings strings, String tag, TagUse use) { + final links = []; + if (use.bookmarks > 0) { + links.add(MewsLink( + MewsUrls.page(Uri.parse(MewsUrls.bookmarks), {'tag': tag}), + [ + MewsText(countText(strings, use.bookmarks, + none: 'count-bookmarks-none', + one: 'count-bookmarks-one', + many: 'count-bookmarks-many')), + ], + )); + } + if (use.sites > 0) { + if (links.isNotEmpty) links.add(const MewsText(actionSeparator)); + links.add(MewsLink( + MewsUrls.page(Uri.parse(MewsUrls.subscriptions), {'tag': tag}), + [ + MewsText(countText(strings, use.sites, + none: 'count-sites-none', + one: 'count-sites-one', + many: 'count-sites-many')), + ], + )); + } + return links; +} + +Future _assign( + Uri url, { + required MewsDatabase db, + required Strings strings, + required TagKind kind, + required String item, +}) async { + final held = await db.tagsOf(kind, item); + final all = (await db.tagCounts()).keys.toList(); + final available = all.where((tag) => !held.contains(tag)).toList(); + final name = await db.itemTitle(kind, item) ?? item; + final title = strings.t('tags-assign-title', {'name': name}); + final back = + kind == TagKind.bookmark ? MewsUrls.bookmarks : MewsUrls.subscriptions; + + return internalPage( + url: url, + title: title, + body: [ + HeadingBlock(1, [MewsText(title)]), + ParagraphBlock([ + MewsLink(back, [MewsText(strings.t('tags-done'))]), + ]), + const RuleBlock(), + // Adding a tag that does not exist yet is how one is made. + textForm( + action: MewsUrls.action('add-tag'), + field: 'tag', + label: strings.t('tags-add-label'), + submitLabel: strings.t('tags-add-submit'), + keep: {'kind': kind.name, 'url': item}, + ), + if (held.isNotEmpty) ...[ + HeadingBlock(2, [MewsText(strings.t('tags-on-item'))]), + itemList([ + for (final tag in held) + itemRow( + href: MewsUrls.page(Uri.parse(back), {'tag': tag}), + label: tag, + actions: [ + action( + MewsUrls.action('remove-tag', + {'kind': kind.name, 'url': item, 'tag': tag}), + strings.t('tags-remove')), + ], + ), + ]), + ], + if (available.isNotEmpty) ...[ + HeadingBlock(2, [MewsText(strings.t('tags-other'))]), + itemList([ + for (final tag in available) + itemRow( + href: MewsUrls.action( + 'add-tag', {'kind': kind.name, 'url': item, 'tag': tag}), + label: tag, + ), + ]), + ], + ], + ); +} diff --git a/app/lib/database/database.dart b/app/lib/database/database.dart index 128ef38..398abb4 100644 --- a/app/lib/database/database.dart +++ b/app/lib/database/database.dart @@ -218,7 +218,7 @@ class MewsDatabase extends _$MewsDatabase { /// Bookmarks in the order they were added, newest first. [query] narrows by /// title or address. - Future> bookmarksByDate({String? query}) async { + Future> 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> allSubscriptions({String? query}) async { + Future> 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 _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> 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 = {}; + 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>> tagsOfMany( + TagKind kind, List 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 = >{}; + for (final row in rows) { + (tags[row.url] ??= []).add(row.tag); + } + return tags; + } + + /// Removes a tag from everything carrying it. + Future 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 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> 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({ diff --git a/app/locales/en-US/main.ftl b/app/locales/en-US/main.ftl index 5b2f793..a3cae99 100644 --- a/app/locales/en-US/main.ftl +++ b/app/locales/en-US/main.ftl @@ -20,6 +20,7 @@ nav-posts = Posts nav-bookmarks = Bookmarks nav-subscriptions = Subscriptions nav-history = History +nav-tags = Tags nav-settings = Settings ## Hub @@ -38,6 +39,9 @@ count-sites-many = { $count } sites count-pages-none = No pages yet count-pages-one = 1 page count-pages-many = { $count } pages +count-tags-none = No tags yet +count-tags-one = 1 tag +count-tags-many = { $count } tags count-unread-none = No unread posts count-unread-one = 1 unread post count-unread-many = { $count } unread posts @@ -66,6 +70,7 @@ action-search = Search action-clear-search = Clear search action-remove-bookmark = Remove bookmark action-unsubscribe = Stop subscribing +action-edit-tags = Edit tags ## Library pages @@ -91,6 +96,25 @@ subscriptions-search-label = Search sites subscriptions-checked = Last checked { $date } subscriptions-never-checked = Not checked yet +## Tags + +tags-title = Tags +tags-empty = Tags you add to bookmarks and sites appear here. +# The item being tagged. Shows its title, or its address until it has one. +tags-assign-title = Tags for { $name } +tags-add-label = Add a tag +tags-add-submit = Add tag +tags-on-item = On this item +tags-other = Other tags +tags-remove = Remove this tag +tags-delete = Delete this tag everywhere +tags-done = Done +bookmarks-show-all = Show all bookmarks +# Shown when a tag narrows the list. +bookmarks-tagged = Showing bookmarks tagged { $tag } +subscriptions-show-all = Show all sites +subscriptions-tagged = Showing sites tagged { $tag } + ## Settings page settings-page-title = Settings diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart index 6e871f0..9a99d1f 100644 --- a/app/test/app_widget_test.dart +++ b/app/test/app_widget_test.dart @@ -59,7 +59,8 @@ void main() { db = MewsDatabase.test(NativeDatabase.memory()); strings = Strings(); strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync()); - deps = AppDependencies(database: db, strings: strings); + deps = AppDependencies( + database: db, fetch: FakeFetch(const {}), strings: strings); }); tearDown(() async { @@ -530,6 +531,63 @@ void main() { expect(deps.bookmarks.isBookmarked('https://a.example/retro.html'), isFalse); }); + testWidgets('tags are made by using them and filter the list', + (tester) async { + await pumpApp(tester); + await deps.bookmarks.load(); + await deps.bookmarks.toggle('https://a.example/retro.html', 'Retro gear'); + await deps.bookmarks.toggle('https://b.example/signal.html', 'Signal bars'); + + // Nothing is tagged yet. + await deps.browser.open(MewsUrls.tags); + await tester.pumpAndSettle(); + expect(find.textContaining('Tags you add'), findsOneWidget); + + // Tagging reaches the item from its own row. + await deps.browser.open(MewsUrls.bookmarks); + await tester.pumpAndSettle(); + await tapRun(tester, 'Edit tags'); + expect(find.textContaining('Tags for'), findsWidgets); + + // Adding a tag that does not exist yet is how one is made. + await tester.enterText(find.byType(TextFormField), 'phones'); + await tester.tap(find.widgetWithText(FilledButton, 'Add tag')); + await tester.pumpAndSettle(); + expect(find.textContaining('On this item'), findsOneWidget); + expect(await deps.db.tagCounts(), contains('phones')); + + // An empty submit does nothing. + await tester.enterText(find.byType(TextFormField), ' '); + await tester.tap(find.widgetWithText(FilledButton, 'Add tag')); + await tester.pumpAndSettle(); + expect((await deps.db.tagCounts()).keys, ['phones']); + + // The tag now shows on the row and narrows the list when followed. + await deps.browser.open(MewsUrls.bookmarks); + await tester.pumpAndSettle(); + await tapRun(tester, 'phones'); + expect(deps.browser.currentPage.value?.url, 'mews:bookmarks?tag=phones'); + expect(find.textContaining('Showing bookmarks tagged phones'), findsOneWidget); + expect(find.textContaining('Signal bars'), findsNothing); + + await tapRun(tester, 'Show all bookmarks'); + expect(deps.browser.currentPage.value?.url, MewsUrls.bookmarks); + expect(find.textContaining('Signal bars'), findsOneWidget); + + // The index counts what carries the tag. + await deps.browser.open(MewsUrls.tags); + await tester.pumpAndSettle(); + expect(find.textContaining('1 bookmark'), findsOneWidget); + + // Deleting it everywhere leaves no trace, because a tag only exists while + // something carries it. + await tapRun(tester, 'Delete this tag everywhere'); + expect(find.textContaining('Tags you add'), findsOneWidget); + expect(await deps.db.tagCounts(), isEmpty); + expect(await deps.db.tagsOf(TagKind.bookmark, 'https://a.example/retro.html'), + isEmpty); + }); + testWidgets('clearing history takes a second step', (tester) async { await pumpApp(tester); await deps.db.recordVisit('https://a.example/one.html', 'Page one');