diff --git a/SPEC.md b/SPEC.md index c7ab56c..1c48013 100644 --- a/SPEC.md +++ b/SPEC.md @@ -68,5 +68,6 @@ 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. +- 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 7a453f0..df0f0b5 100644 --- a/app/lib/application/internal_actions.dart +++ b/app/lib/application/internal_actions.dart @@ -1,3 +1,5 @@ +import '../database/database.dart'; +import 'controllers/bookmarks_controller.dart'; import 'controllers/reader_settings_controller.dart' show ReaderSettingsController; import 'controllers/subscriptions_controller.dart'; @@ -12,12 +14,18 @@ import 'pages/mews_urls.dart'; /// miss is a bug rather than something to explain to the reader. class InternalActions { InternalActions({ + required MewsDatabase db, required ReaderSettingsController settings, + required BookmarksController bookmarks, required SubscriptionsController subscriptions, - }) : _settings = settings, + }) : _db = db, + _settings = settings, + _bookmarks = bookmarks, _subscriptions = subscriptions; + final MewsDatabase _db; final ReaderSettingsController _settings; + final BookmarksController _bookmarks; final SubscriptionsController _subscriptions; Future apply(String url) async { @@ -33,6 +41,10 @@ class InternalActions { 'mark-unread' => _markRead(args, read: false), 'refresh-subscriptions' => _refreshAll(), 'refresh-subscription' => _refreshOne(args['url']), + 'remove-bookmark' => _withUrl(args, _bookmarks.remove), + 'unsubscribe' => _withUrl(args, _subscriptions.remove), + 'forget-page' => _withUrl(args, _db.forgetPage), + 'clear-history' => _clearHistory(), _ => false, }; } @@ -43,6 +55,20 @@ class InternalActions { _ => Future.value(false), }; + /// Runs [change] on the address the action names, if it names one. + Future _withUrl( + Map args, Future Function(String) change) async { + final url = args['url']; + if (url == null || url.isEmpty) return false; + await change(url); + return true; + } + + Future _clearHistory() async { + await _db.clearHistory(); + return true; + } + Future _markRead(Map args, {required bool read}) async { final url = args['url']; if (url == null || url.isEmpty) return false; diff --git a/app/lib/application/pages/bookmarks_page.dart b/app/lib/application/pages/bookmarks_page.dart index bfe3136..19d7c44 100644 --- a/app/lib/application/pages/bookmarks_page.dart +++ b/app/lib/application/pages/bookmarks_page.dart @@ -1,6 +1,7 @@ import '../../database/database.dart'; import '../../l10n/strings.dart'; import '../../mews/document.dart'; +import 'mews_urls.dart'; import 'page_blocks.dart'; Future buildBookmarksPage( @@ -8,7 +9,8 @@ Future buildBookmarksPage( required MewsDatabase db, required Strings strings, }) async { - final bookmarks = await db.bookmarksByDate(); + final query = url.queryParameters['q'] ?? ''; + final bookmarks = await db.bookmarksByDate(query: query); final title = strings.t('bookmarks-title'); return internalPage( @@ -16,8 +18,22 @@ Future buildBookmarksPage( title: title, body: [ ...chrome(title: title, current: url, strings: strings), + searchForm( + action: MewsUrls.bookmarks, + label: strings.t('bookmarks-search-label'), + submitLabel: strings.t('action-search'), + query: query, + ), + if (query.isNotEmpty) + ParagraphBlock([ + MewsLink(MewsUrls.page(url, {'q': null}), + [MewsText(strings.t('action-clear-search'))]), + ]), if (bookmarks.isEmpty) - ParagraphBlock([MewsText(strings.t('bookmarks-empty'))]) + ParagraphBlock([ + MewsText(strings.t( + query.isEmpty ? 'bookmarks-empty' : 'bookmarks-no-matches')), + ]) else itemList([ for (final bookmark in bookmarks) @@ -25,6 +41,11 @@ Future buildBookmarksPage( href: bookmark.url, label: bookmark.title ?? bookmark.url, date: dayText(bookmark.createdAt), + actions: [ + action( + MewsUrls.action('remove-bookmark', {'url': bookmark.url}), + strings.t('action-remove-bookmark')), + ], ), ]), ], diff --git a/app/lib/application/pages/history_page.dart b/app/lib/application/pages/history_page.dart index f244a7b..2f11d48 100644 --- a/app/lib/application/pages/history_page.dart +++ b/app/lib/application/pages/history_page.dart @@ -1,6 +1,7 @@ import '../../database/database.dart'; import '../../l10n/strings.dart'; import '../../mews/document.dart'; +import 'mews_urls.dart'; import 'page_blocks.dart'; Future buildHistoryPage( @@ -8,7 +9,9 @@ Future buildHistoryPage( required MewsDatabase db, required Strings strings, }) async { - final visits = await db.recentVisits(); + final query = url.queryParameters['q'] ?? ''; + final confirming = url.queryParameters['confirm'] == 'clear'; + final pages = await db.recentPages(query: query); final title = strings.t('history-title'); return internalPage( @@ -16,15 +19,49 @@ Future buildHistoryPage( title: title, body: [ ...chrome(title: title, current: url, strings: strings), - if (visits.isEmpty) - ParagraphBlock([MewsText(strings.t('history-empty'))]) + searchForm( + action: MewsUrls.history, + label: strings.t('history-search-label'), + submitLabel: strings.t('action-search'), + query: query, + ), + // Clearing history cannot be undone, so it takes a second step rather + // than a dialog: another page the reader can simply leave. + if (confirming) ...[ + ParagraphBlock([MewsText(strings.t('history-clear-confirm'))]), + ParagraphBlock([ + MewsLink(MewsUrls.action('clear-history'), + [MewsText(strings.t('history-clear-yes'))]), + const MewsText(actionSeparator), + MewsLink(MewsUrls.history, [MewsText(strings.t('history-clear-no'))]), + ]), + ] else + ParagraphBlock([ + if (query.isNotEmpty) ...[ + MewsLink(MewsUrls.page(url, {'q': null}), + [MewsText(strings.t('action-clear-search'))]), + const MewsText(actionSeparator), + ], + if (pages.isNotEmpty) + MewsLink(MewsUrls.page(url, {'confirm': 'clear'}), + [MewsText(strings.t('history-clear'))]), + ]), + if (pages.isEmpty) + ParagraphBlock([ + MewsText(strings + .t(query.isEmpty ? 'history-empty' : 'history-no-matches')), + ]) else itemList([ - for (final visit in visits) + for (final page in pages) itemRow( - href: visit.url, - label: visit.title ?? visit.url, - date: dayTimeText(visit.visitedAt), + href: page.url, + label: page.title ?? page.url, + date: dayTimeText(page.visitedAt), + actions: [ + action(MewsUrls.action('forget-page', {'url': page.url}), + strings.t('history-forget')), + ], ), ]), ], diff --git a/app/lib/application/pages/page_blocks.dart b/app/lib/application/pages/page_blocks.dart index a29dda6..9790baa 100644 --- a/app/lib/application/pages/page_blocks.dart +++ b/app/lib/application/pages/page_blocks.dart @@ -139,6 +139,33 @@ 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. +/// +/// 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({ + required String action, + required String label, + required String submitLabel, + required String query, + Map keep = const {}, +}) { + return FormBlock( + action: action, + method: 'get', + submitLabel: submitLabel, + fields: [ + MewsTextField(name: 'q', label: label, password: false, value: query), + for (final kept in keep.entries) + MewsHiddenField(name: kept.key, value: kept.value), + MewsSubmitField(reset: false, label: submitLabel), + ], + ); +} + /// A plain list of links, used for menus and library lists alike. ListBlock itemList(List items) => ListBlock(ordered: false, start: 1, items: items); diff --git a/app/lib/application/pages/subscriptions_page.dart b/app/lib/application/pages/subscriptions_page.dart index 8400cbe..711a22b 100644 --- a/app/lib/application/pages/subscriptions_page.dart +++ b/app/lib/application/pages/subscriptions_page.dart @@ -1,6 +1,7 @@ import '../../database/database.dart'; import '../../l10n/strings.dart'; import '../../mews/document.dart'; +import 'mews_urls.dart'; import 'page_blocks.dart'; Future buildSubscriptionsPage( @@ -8,7 +9,8 @@ Future buildSubscriptionsPage( required MewsDatabase db, required Strings strings, }) async { - final sites = await db.allSubscriptions(); + final query = url.queryParameters['q'] ?? ''; + final sites = await db.allSubscriptions(query: query); final unread = await db.unreadCounts(); final title = strings.t('subscriptions-title'); @@ -17,28 +19,53 @@ Future buildSubscriptionsPage( title: title, body: [ ...chrome(title: title, current: url, strings: strings), + searchForm( + action: MewsUrls.subscriptions, + label: strings.t('subscriptions-search-label'), + submitLabel: strings.t('action-search'), + query: query, + ), + if (query.isNotEmpty) + ParagraphBlock([ + MewsLink(MewsUrls.page(url, {'q': null}), + [MewsText(strings.t('action-clear-search'))]), + ]), if (sites.isEmpty) - ParagraphBlock([MewsText(strings.t('subscriptions-empty'))]) + ParagraphBlock([ + MewsText(strings.t(query.isEmpty + ? 'subscriptions-empty' + : 'subscriptions-no-matches')), + ]) else itemList([ for (final site in sites) itemRow( href: site.url, label: site.title ?? site.url, - date: dayText(site.createdAt), - note: _unreadNote(strings, unread[site.url] ?? 0), + note: _checkedNote(strings, site, unread[site.url] ?? 0), + actions: [ + action( + MewsUrls.action('refresh-subscription', {'url': site.url}), + strings.t('action-refresh')), + action(MewsUrls.action('unsubscribe', {'url': site.url}), + strings.t('action-unsubscribe')), + ], ), ]), ], ); } -/// The unread count for a site, or null when it has nothing unread, so the -/// row carries no second line. -String? _unreadNote(Strings strings, int count) { - if (count == 0) return null; - return countText(strings, count, - none: 'count-unread-none', - one: 'count-unread-one', - many: 'count-unread-many'); +/// What the reader needs to know about a site: what is waiting, or when it was +/// last looked at. Each is a whole message of its own. +String _checkedNote(Strings strings, Subscription site, int unread) { + if (unread > 0) { + return countText(strings, unread, + none: 'count-unread-none', + one: 'count-unread-one', + many: 'count-unread-many'); + } + final checkedAt = site.lastCheckedAt; + if (checkedAt == null) return strings.t('subscriptions-never-checked'); + return strings.t('subscriptions-checked', {'date': dayText(checkedAt)}); } diff --git a/app/lib/database/database.dart b/app/lib/database/database.dart index 54543e1..128ef38 100644 --- a/app/lib/database/database.dart +++ b/app/lib/database/database.dart @@ -162,26 +162,72 @@ class MewsDatabase extends _$MewsDatabase { return (await query.getSingle()).read(total) ?? 0; } - /// The most recent external visits, newest first. - Future> recentVisits({int limit = 50}) async { - final query = (select(historyItems) - ..orderBy([ - (r) => OrderingTerm.desc(r.visitedAt), - (r) => OrderingTerm.desc(r.id), - ]) - ..limit(limit)); - return query.get(); + /// The most recently visited pages, newest first, one row per address so a + /// page visited often is not listed over and over. [query] narrows by title + /// or address. + Future> recentPages({String? query, int limit = 200}) async { + // Group on the highest row id rather than the latest timestamp: visits + // recorded within the same clock tick share a timestamp, and sqlite then + // picks any one of their rows for the plain columns below. Ids always + // climb, so the newest visit is the one with the largest id. + final lastVisit = historyItems.id.max(); + final statement = selectOnly(historyItems) + ..addColumns([ + historyItems.url, + historyItems.title, + historyItems.visitedAt, + lastVisit, + ]) + ..groupBy([historyItems.url]) + ..orderBy([OrderingTerm.desc(lastVisit)]) + ..limit(limit); + final filter = _search([historyItems.url, historyItems.title], query); + if (filter != null) statement.where(filter); + + return (await statement.get()).map((row) { + return VisitedPage( + // Selecting a plain column alongside max() gives the value from the + // row that matched it, so these describe the latest visit. + url: row.read(historyItems.url)!, + title: row.read(historyItems.title), + visitedAt: row.read(historyItems.visitedAt)!, + ); + }).toList(); } - /// Bookmarks in the order they were added, newest first. - Future> bookmarksByDate() async { - final query = (select(bookmarks) + /// Removes every visit to one page. + Future forgetPage(String url) => + (delete(historyItems)..where((r) => r.url.equals(url))).go(); + + Future clearHistory() => delete(historyItems).go(); + + /// Narrows a list to rows where any of [columns] contains [query]. Returns + /// null when there is nothing to search for, so the caller adds no clause. + Expression? _search( + List> columns, String? query) { + final needle = query?.trim(); + if (needle == null || needle.isEmpty) return null; + // The reader is typing text, not a pattern, so LIKE's wildcards are + // escaped and matched literally. + final pattern = + '%${needle.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%'; + return columns + .map((column) => column.like(pattern, escapeChar: '\\')) + .reduce((a, b) => a | b); + } + + /// Bookmarks in the order they were added, newest first. [query] narrows by + /// title or address. + Future> bookmarksByDate({String? query}) async { + final statement = (select(bookmarks) ..orderBy([ (r) => OrderingTerm.desc(r.createdAt), // Tiebreak bookmarks added within the same clock tick. (r) => OrderingTerm.asc(r.url), ])); - return query.get(); + final filter = _search([bookmarks.url, bookmarks.title], query); + if (filter != null) statement.where((_) => filter); + return statement.get(); } // Bookmarks ----------------------------------------------------------- @@ -214,13 +260,16 @@ class MewsDatabase extends _$MewsDatabase { await _removeTagsOf(TagKind.subscription, url); }); - Future> allSubscriptions() async { - final query = (select(subscriptions) + /// Subscribed sites, newest first. [query] narrows by title or address. + Future> allSubscriptions({String? query}) async { + final statement = (select(subscriptions) ..orderBy([ (r) => OrderingTerm.desc(r.createdAt), (r) => OrderingTerm.asc(r.url), ])); - return query.get(); + final filter = _search([subscriptions.url, subscriptions.title], query); + if (filter != null) statement.where((_) => filter); + return statement.get(); } /// Records that a site was checked, whether or not anything was found. @@ -432,6 +481,19 @@ enum EntrySort { }; } +/// One page the reader visited, collapsed to its latest visit. +class VisitedPage { + const VisitedPage({ + required this.url, + required this.title, + required this.visitedAt, + }); + + final String url; + final String? title; + final DateTime visitedAt; +} + /// One post together with the site that published it. class RiverEntry { const RiverEntry({ diff --git a/app/lib/presentation/app.dart b/app/lib/presentation/app.dart index 4a7a7ae..ea123a1 100644 --- a/app/lib/presentation/app.dart +++ b/app/lib/presentation/app.dart @@ -35,8 +35,12 @@ class AppDependencies { settings: settings, subscriptions: subscriptions, strings: this.strings); - internalActions = - InternalActions(settings: settings, subscriptions: subscriptions); + internalActions = InternalActions( + db: db, + settings: settings, + bookmarks: bookmarks, + subscriptions: subscriptions, + ); browser = BrowserController( db: db, fetch: this.fetch, diff --git a/app/locales/en-US/main.ftl b/app/locales/en-US/main.ftl index 696be98..5b2f793 100644 --- a/app/locales/en-US/main.ftl +++ b/app/locales/en-US/main.ftl @@ -62,16 +62,34 @@ posts-mark-unread = Mark as unread # Link labels used on more than one page. action-refresh = Check for new posts +action-search = Search +action-clear-search = Clear search +action-remove-bookmark = Remove bookmark +action-unsubscribe = Stop subscribing ## Library pages # Internal pages rendered like ordinary Mews pages. history-title = History history-empty = Pages you visit appear here. +history-no-matches = No pages match your search. +history-search-label = Search history +history-forget = Remove from history +history-clear = Clear history +history-clear-confirm = Clearing history removes every page you visited. You can't undo this. +history-clear-yes = Clear history now +history-clear-no = Keep my history bookmarks-title = Bookmarks bookmarks-empty = Pages you bookmark appear here. +bookmarks-no-matches = No bookmarks match your search. +bookmarks-search-label = Search bookmarks subscriptions-title = Subscriptions subscriptions-empty = Sites you subscribe to appear here. +subscriptions-no-matches = No sites match your search. +subscriptions-search-label = Search sites +# When a site was last checked for posts. Format the date in code. +subscriptions-checked = Last checked { $date } +subscriptions-never-checked = Not checked yet ## Settings page diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart index 92c944a..6e871f0 100644 --- a/app/test/app_widget_test.dart +++ b/app/test/app_widget_test.dart @@ -422,9 +422,9 @@ void main() { await tester.pumpAndSettle(); expect(stubDeps.browser.currentPage.value?.url, 'mews:history'); - // A history link navigates like any other link. - await tester.tap(find.textContaining('Page A')); - await tester.pumpAndSettle(); + // A history link navigates like any other link. Rows carry their own + // actions now, so tap the title itself rather than the row's middle. + await tapRun(tester, 'Page A'); expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html'); // Bookmarks and subscriptions get the same treatment. @@ -484,6 +484,102 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('bookmarks can be searched and removed from their own page', + (tester) async { + await pumpApp(tester); + await deps.bookmarks.load(); + await deps.bookmarks.toggle('https://a.example/retro.html', 'Retro phones'); + await deps.bookmarks.toggle('https://b.example/signal.html', 'Signal bars'); + + await deps.browser.open(MewsUrls.bookmarks); + await tester.pumpAndSettle(); + expect(find.textContaining('Retro phones'), findsOneWidget); + expect(find.textContaining('Signal bars'), findsOneWidget); + + // Searching narrows the list and the choice lands in the address. + await tester.enterText(find.byType(TextFormField), 'retro'); + await tester.tap(find.widgetWithText(FilledButton, 'Search')); + await tester.pumpAndSettle(); + expect(deps.browser.currentPage.value?.url, 'mews:bookmarks?q=retro'); + expect(find.textContaining('Retro phones'), findsOneWidget); + expect(find.textContaining('Signal bars'), findsNothing); + + // A search with no matches says so rather than looking empty. + await deps.browser.open('mews:bookmarks?q=nothinghere'); + await tester.pumpAndSettle(); + expect(find.textContaining('No bookmarks match'), findsOneWidget); + + // Clearing the search brings the whole list back. + await deps.browser.open('mews:bookmarks?q=retro'); + await tester.pumpAndSettle(); + await tapRun(tester, 'Clear search'); + expect(deps.browser.currentPage.value?.url, MewsUrls.bookmarks); + expect(find.textContaining('Signal bars'), findsOneWidget); + + // Removing one stays on the page and adds no walk history entry. What the + // reader has typed survives, because the renderer matches form state by + // position and the search box keeps its place in the page. + await tester.enterText(find.byType(TextFormField), 'half typed'); + final stepsBack = deps.browser.canGoBack; + await tapRun(tester, 'Remove bookmark'); + expect(find.widgetWithText(TextFormField, 'half typed'), findsOneWidget); + expect(deps.browser.currentPage.value?.url, MewsUrls.bookmarks); + expect(deps.browser.canGoBack, stepsBack); + expect(find.textContaining('Retro phones'), findsNothing); + expect(find.textContaining('Signal bars'), findsOneWidget); + expect(deps.bookmarks.isBookmarked('https://a.example/retro.html'), isFalse); + }); + + testWidgets('clearing history takes a second step', (tester) async { + await pumpApp(tester); + await deps.db.recordVisit('https://a.example/one.html', 'Page one'); + await deps.db.recordVisit('https://a.example/one.html', 'Page one'); + await deps.db.recordVisit('https://b.example/two.html', 'Page two'); + + await deps.browser.open(MewsUrls.history); + await tester.pumpAndSettle(); + // A page visited twice is listed once. + expect(find.textContaining('Page one'), findsOneWidget); + expect(find.textContaining('Page two'), findsOneWidget); + + // Backing out of the confirmation leaves history alone. + await tapRun(tester, 'Clear history'); + expect(deps.browser.currentPage.value?.url, 'mews:history?confirm=clear'); + expect(find.textContaining("can't undo"), findsOneWidget); + await tapRun(tester, 'Keep my history'); + expect(deps.browser.currentPage.value?.url, MewsUrls.history); + expect(find.textContaining('Page one'), findsOneWidget); + + // Removing one page leaves the other. The most recent visit heads the + // list, so the first row is page two. + await tapRun(tester, 'Remove from history'); + await tester.pumpAndSettle(); + expect(find.textContaining('Page two'), findsNothing); + expect(find.textContaining('Page one'), findsOneWidget); + + // Confirming clears the rest. + await tapRun(tester, 'Clear history'); + await tapRun(tester, 'Clear history now'); + expect(find.textContaining('Pages you visit appear here'), findsOneWidget); + }); + + testWidgets('a site can be left from the subscriptions page', (tester) async { + await pumpApp(tester); + await deps.subscriptions.load(); + await deps.subscriptions + .toggle(parseSource(pageSource, 'https://example.com/index.html')); + + await deps.browser.open(MewsUrls.subscriptions); + await tester.pumpAndSettle(); + expect(find.textContaining('Signal Bars'), findsOneWidget); + + await tapRun(tester, 'Stop subscribing'); + expect(deps.browser.currentPage.value?.url, MewsUrls.subscriptions); + expect(deps.subscriptions.isSubscribed('https://example.com/index.html'), + isFalse); + expect(find.textContaining('Sites you subscribe to'), findsOneWidget); + }); + testWidgets('the posts page lists, orders and filters posts', (tester) async { const siteA = 'https://a.example/'; diff --git a/app/test/database_test.dart b/app/test/database_test.dart index a9ef053..35babfb 100644 --- a/app/test/database_test.dart +++ b/app/test/database_test.dart @@ -93,6 +93,45 @@ void main() { expect(await db.countHistory(), 2); }); + test('a search matches titles and addresses literally', () async { + await db.addBookmark('https://a.example/retro.html', 'Collecting retro phones'); + await db.addBookmark('https://b.example/signal.html', 'Signal bars'); + await db.addBookmark('https://c.example/100.html', '100% signal'); + + expect((await db.bookmarksByDate(query: 'retro')).map((b) => b.title), + ['Collecting retro phones']); + // The address is searched too. + expect((await db.bookmarksByDate(query: 'b.example')).map((b) => b.title), + ['Signal bars']); + // A blank search is not a filter. + expect(await db.bookmarksByDate(query: ' '), hasLength(3)); + + // The reader types text, not a pattern: LIKE's wildcards match literally. + expect(await db.bookmarksByDate(query: '%'), hasLength(1)); + expect((await db.bookmarksByDate(query: '100%')).map((b) => b.title), + ['100% signal']); + expect(await db.bookmarksByDate(query: '_'), isEmpty); + }); + + test('history lists one row per address, newest visit first', () async { + await db.recordVisit('https://a.example/one.html', 'First title'); + await db.recordVisit('https://b.example/two.html', 'Page two'); + await db.recordVisit('https://a.example/one.html', 'Newer title'); + + final pages = await db.recentPages(); + expect(pages.map((p) => p.url), + ['https://a.example/one.html', 'https://b.example/two.html']); + // The row carries the title of the latest visit. + expect(pages.first.title, 'Newer title'); + + await db.forgetPage('https://a.example/one.html'); + expect((await db.recentPages()).map((p) => p.url), + ['https://b.example/two.html']); + + await db.clearHistory(); + expect(await db.recentPages(), isEmpty); + }); + test('an older database is recreated rather than migrated', () async { final dir = Directory.systemTemp.createTempSync('mews-schema'); addTearDown(() => dir.deleteSync(recursive: true));