feat: search, filter and act on items from the library pages
This commit is contained in:
parent
1b426ccd8b
commit
5cd053c8a9
11 changed files with 401 additions and 43 deletions
1
SPEC.md
1
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.
|
- 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.
|
- `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.
|
- 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/<verb>` 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.
|
- A change is a link too: following `mews:do/<verb>` 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`.
|
- 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`.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import '../database/database.dart';
|
||||||
|
import 'controllers/bookmarks_controller.dart';
|
||||||
import 'controllers/reader_settings_controller.dart'
|
import 'controllers/reader_settings_controller.dart'
|
||||||
show ReaderSettingsController;
|
show ReaderSettingsController;
|
||||||
import 'controllers/subscriptions_controller.dart';
|
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.
|
/// miss is a bug rather than something to explain to the reader.
|
||||||
class InternalActions {
|
class InternalActions {
|
||||||
InternalActions({
|
InternalActions({
|
||||||
|
required MewsDatabase db,
|
||||||
required ReaderSettingsController settings,
|
required ReaderSettingsController settings,
|
||||||
|
required BookmarksController bookmarks,
|
||||||
required SubscriptionsController subscriptions,
|
required SubscriptionsController subscriptions,
|
||||||
}) : _settings = settings,
|
}) : _db = db,
|
||||||
|
_settings = settings,
|
||||||
|
_bookmarks = bookmarks,
|
||||||
_subscriptions = subscriptions;
|
_subscriptions = subscriptions;
|
||||||
|
|
||||||
|
final MewsDatabase _db;
|
||||||
final ReaderSettingsController _settings;
|
final ReaderSettingsController _settings;
|
||||||
|
final BookmarksController _bookmarks;
|
||||||
final SubscriptionsController _subscriptions;
|
final SubscriptionsController _subscriptions;
|
||||||
|
|
||||||
Future<bool> apply(String url) async {
|
Future<bool> apply(String url) async {
|
||||||
|
|
@ -33,6 +41,10 @@ class InternalActions {
|
||||||
'mark-unread' => _markRead(args, read: false),
|
'mark-unread' => _markRead(args, read: false),
|
||||||
'refresh-subscriptions' => _refreshAll(),
|
'refresh-subscriptions' => _refreshAll(),
|
||||||
'refresh-subscription' => _refreshOne(args['url']),
|
'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,
|
_ => false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -43,6 +55,20 @@ class InternalActions {
|
||||||
_ => Future.value(false),
|
_ => Future.value(false),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Runs [change] on the address the action names, if it names one.
|
||||||
|
Future<bool> _withUrl(
|
||||||
|
Map<String, String> args, Future<void> Function(String) change) async {
|
||||||
|
final url = args['url'];
|
||||||
|
if (url == null || url.isEmpty) return false;
|
||||||
|
await change(url);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _clearHistory() async {
|
||||||
|
await _db.clearHistory();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> _markRead(Map<String, String> args, {required bool read}) async {
|
Future<bool> _markRead(Map<String, String> args, {required bool read}) async {
|
||||||
final url = args['url'];
|
final url = args['url'];
|
||||||
if (url == null || url.isEmpty) return false;
|
if (url == null || url.isEmpty) return false;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
import '../../l10n/strings.dart';
|
import '../../l10n/strings.dart';
|
||||||
import '../../mews/document.dart';
|
import '../../mews/document.dart';
|
||||||
|
import 'mews_urls.dart';
|
||||||
import 'page_blocks.dart';
|
import 'page_blocks.dart';
|
||||||
|
|
||||||
Future<MewsPage> buildBookmarksPage(
|
Future<MewsPage> buildBookmarksPage(
|
||||||
|
|
@ -8,7 +9,8 @@ Future<MewsPage> buildBookmarksPage(
|
||||||
required MewsDatabase db,
|
required MewsDatabase db,
|
||||||
required Strings strings,
|
required Strings strings,
|
||||||
}) async {
|
}) async {
|
||||||
final bookmarks = await db.bookmarksByDate();
|
final query = url.queryParameters['q'] ?? '';
|
||||||
|
final bookmarks = await db.bookmarksByDate(query: query);
|
||||||
final title = strings.t('bookmarks-title');
|
final title = strings.t('bookmarks-title');
|
||||||
|
|
||||||
return internalPage(
|
return internalPage(
|
||||||
|
|
@ -16,8 +18,22 @@ Future<MewsPage> buildBookmarksPage(
|
||||||
title: title,
|
title: title,
|
||||||
body: [
|
body: [
|
||||||
...chrome(title: title, current: url, strings: strings),
|
...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)
|
if (bookmarks.isEmpty)
|
||||||
ParagraphBlock([MewsText(strings.t('bookmarks-empty'))])
|
ParagraphBlock([
|
||||||
|
MewsText(strings.t(
|
||||||
|
query.isEmpty ? 'bookmarks-empty' : 'bookmarks-no-matches')),
|
||||||
|
])
|
||||||
else
|
else
|
||||||
itemList([
|
itemList([
|
||||||
for (final bookmark in bookmarks)
|
for (final bookmark in bookmarks)
|
||||||
|
|
@ -25,6 +41,11 @@ Future<MewsPage> buildBookmarksPage(
|
||||||
href: bookmark.url,
|
href: bookmark.url,
|
||||||
label: bookmark.title ?? bookmark.url,
|
label: bookmark.title ?? bookmark.url,
|
||||||
date: dayText(bookmark.createdAt),
|
date: dayText(bookmark.createdAt),
|
||||||
|
actions: [
|
||||||
|
action(
|
||||||
|
MewsUrls.action('remove-bookmark', {'url': bookmark.url}),
|
||||||
|
strings.t('action-remove-bookmark')),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
import '../../l10n/strings.dart';
|
import '../../l10n/strings.dart';
|
||||||
import '../../mews/document.dart';
|
import '../../mews/document.dart';
|
||||||
|
import 'mews_urls.dart';
|
||||||
import 'page_blocks.dart';
|
import 'page_blocks.dart';
|
||||||
|
|
||||||
Future<MewsPage> buildHistoryPage(
|
Future<MewsPage> buildHistoryPage(
|
||||||
|
|
@ -8,7 +9,9 @@ Future<MewsPage> buildHistoryPage(
|
||||||
required MewsDatabase db,
|
required MewsDatabase db,
|
||||||
required Strings strings,
|
required Strings strings,
|
||||||
}) async {
|
}) 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');
|
final title = strings.t('history-title');
|
||||||
|
|
||||||
return internalPage(
|
return internalPage(
|
||||||
|
|
@ -16,15 +19,49 @@ Future<MewsPage> buildHistoryPage(
|
||||||
title: title,
|
title: title,
|
||||||
body: [
|
body: [
|
||||||
...chrome(title: title, current: url, strings: strings),
|
...chrome(title: title, current: url, strings: strings),
|
||||||
if (visits.isEmpty)
|
searchForm(
|
||||||
ParagraphBlock([MewsText(strings.t('history-empty'))])
|
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
|
else
|
||||||
itemList([
|
itemList([
|
||||||
for (final visit in visits)
|
for (final page in pages)
|
||||||
itemRow(
|
itemRow(
|
||||||
href: visit.url,
|
href: page.url,
|
||||||
label: visit.title ?? visit.url,
|
label: page.title ?? page.url,
|
||||||
date: dayTimeText(visit.visitedAt),
|
date: dayTimeText(page.visitedAt),
|
||||||
|
actions: [
|
||||||
|
action(MewsUrls.action('forget-page', {'url': page.url}),
|
||||||
|
strings.t('history-forget')),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,33 @@ ListBlock choiceRow(List<Choice> 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<String, String> 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.
|
/// A plain list of links, used for menus and library lists alike.
|
||||||
ListBlock itemList(List<MewsListItem> items) =>
|
ListBlock itemList(List<MewsListItem> items) =>
|
||||||
ListBlock(ordered: false, start: 1, items: items);
|
ListBlock(ordered: false, start: 1, items: items);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
import '../../l10n/strings.dart';
|
import '../../l10n/strings.dart';
|
||||||
import '../../mews/document.dart';
|
import '../../mews/document.dart';
|
||||||
|
import 'mews_urls.dart';
|
||||||
import 'page_blocks.dart';
|
import 'page_blocks.dart';
|
||||||
|
|
||||||
Future<MewsPage> buildSubscriptionsPage(
|
Future<MewsPage> buildSubscriptionsPage(
|
||||||
|
|
@ -8,7 +9,8 @@ Future<MewsPage> buildSubscriptionsPage(
|
||||||
required MewsDatabase db,
|
required MewsDatabase db,
|
||||||
required Strings strings,
|
required Strings strings,
|
||||||
}) async {
|
}) async {
|
||||||
final sites = await db.allSubscriptions();
|
final query = url.queryParameters['q'] ?? '';
|
||||||
|
final sites = await db.allSubscriptions(query: query);
|
||||||
final unread = await db.unreadCounts();
|
final unread = await db.unreadCounts();
|
||||||
final title = strings.t('subscriptions-title');
|
final title = strings.t('subscriptions-title');
|
||||||
|
|
||||||
|
|
@ -17,28 +19,53 @@ Future<MewsPage> buildSubscriptionsPage(
|
||||||
title: title,
|
title: title,
|
||||||
body: [
|
body: [
|
||||||
...chrome(title: title, current: url, strings: strings),
|
...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)
|
if (sites.isEmpty)
|
||||||
ParagraphBlock([MewsText(strings.t('subscriptions-empty'))])
|
ParagraphBlock([
|
||||||
|
MewsText(strings.t(query.isEmpty
|
||||||
|
? 'subscriptions-empty'
|
||||||
|
: 'subscriptions-no-matches')),
|
||||||
|
])
|
||||||
else
|
else
|
||||||
itemList([
|
itemList([
|
||||||
for (final site in sites)
|
for (final site in sites)
|
||||||
itemRow(
|
itemRow(
|
||||||
href: site.url,
|
href: site.url,
|
||||||
label: site.title ?? site.url,
|
label: site.title ?? site.url,
|
||||||
date: dayText(site.createdAt),
|
note: _checkedNote(strings, site, unread[site.url] ?? 0),
|
||||||
note: _unreadNote(strings, 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
|
/// What the reader needs to know about a site: what is waiting, or when it was
|
||||||
/// row carries no second line.
|
/// last looked at. Each is a whole message of its own.
|
||||||
String? _unreadNote(Strings strings, int count) {
|
String _checkedNote(Strings strings, Subscription site, int unread) {
|
||||||
if (count == 0) return null;
|
if (unread > 0) {
|
||||||
return countText(strings, count,
|
return countText(strings, unread,
|
||||||
none: 'count-unread-none',
|
none: 'count-unread-none',
|
||||||
one: 'count-unread-one',
|
one: 'count-unread-one',
|
||||||
many: 'count-unread-many');
|
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)});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,26 +162,72 @@ class MewsDatabase extends _$MewsDatabase {
|
||||||
return (await query.getSingle()).read(total) ?? 0;
|
return (await query.getSingle()).read(total) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The most recent external visits, newest first.
|
/// The most recently visited pages, newest first, one row per address so a
|
||||||
Future<List<HistoryItem>> recentVisits({int limit = 50}) async {
|
/// page visited often is not listed over and over. [query] narrows by title
|
||||||
final query = (select(historyItems)
|
/// or address.
|
||||||
..orderBy([
|
Future<List<VisitedPage>> recentPages({String? query, int limit = 200}) async {
|
||||||
(r) => OrderingTerm.desc(r.visitedAt),
|
// Group on the highest row id rather than the latest timestamp: visits
|
||||||
(r) => OrderingTerm.desc(r.id),
|
// 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
|
||||||
..limit(limit));
|
// climb, so the newest visit is the one with the largest id.
|
||||||
return query.get();
|
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.
|
/// Removes every visit to one page.
|
||||||
Future<List<Bookmark>> bookmarksByDate() async {
|
Future<void> forgetPage(String url) =>
|
||||||
final query = (select(bookmarks)
|
(delete(historyItems)..where((r) => r.url.equals(url))).go();
|
||||||
|
|
||||||
|
Future<void> 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<bool>? _search(
|
||||||
|
List<GeneratedColumn<String>> 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<List<Bookmark>> bookmarksByDate({String? query}) async {
|
||||||
|
final statement = (select(bookmarks)
|
||||||
..orderBy([
|
..orderBy([
|
||||||
(r) => OrderingTerm.desc(r.createdAt),
|
(r) => OrderingTerm.desc(r.createdAt),
|
||||||
// Tiebreak bookmarks added within the same clock tick.
|
// Tiebreak bookmarks added within the same clock tick.
|
||||||
(r) => OrderingTerm.asc(r.url),
|
(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 -----------------------------------------------------------
|
// Bookmarks -----------------------------------------------------------
|
||||||
|
|
@ -214,13 +260,16 @@ class MewsDatabase extends _$MewsDatabase {
|
||||||
await _removeTagsOf(TagKind.subscription, url);
|
await _removeTagsOf(TagKind.subscription, url);
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<List<Subscription>> allSubscriptions() async {
|
/// Subscribed sites, newest first. [query] narrows by title or address.
|
||||||
final query = (select(subscriptions)
|
Future<List<Subscription>> allSubscriptions({String? query}) async {
|
||||||
|
final statement = (select(subscriptions)
|
||||||
..orderBy([
|
..orderBy([
|
||||||
(r) => OrderingTerm.desc(r.createdAt),
|
(r) => OrderingTerm.desc(r.createdAt),
|
||||||
(r) => OrderingTerm.asc(r.url),
|
(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.
|
/// 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.
|
/// One post together with the site that published it.
|
||||||
class RiverEntry {
|
class RiverEntry {
|
||||||
const RiverEntry({
|
const RiverEntry({
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,12 @@ class AppDependencies {
|
||||||
settings: settings,
|
settings: settings,
|
||||||
subscriptions: subscriptions,
|
subscriptions: subscriptions,
|
||||||
strings: this.strings);
|
strings: this.strings);
|
||||||
internalActions =
|
internalActions = InternalActions(
|
||||||
InternalActions(settings: settings, subscriptions: subscriptions);
|
db: db,
|
||||||
|
settings: settings,
|
||||||
|
bookmarks: bookmarks,
|
||||||
|
subscriptions: subscriptions,
|
||||||
|
);
|
||||||
browser = BrowserController(
|
browser = BrowserController(
|
||||||
db: db,
|
db: db,
|
||||||
fetch: this.fetch,
|
fetch: this.fetch,
|
||||||
|
|
|
||||||
|
|
@ -62,16 +62,34 @@ posts-mark-unread = Mark as unread
|
||||||
|
|
||||||
# Link labels used on more than one page.
|
# Link labels used on more than one page.
|
||||||
action-refresh = Check for new posts
|
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
|
## Library pages
|
||||||
|
|
||||||
# Internal pages rendered like ordinary Mews pages.
|
# Internal pages rendered like ordinary Mews pages.
|
||||||
history-title = History
|
history-title = History
|
||||||
history-empty = Pages you visit appear here.
|
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-title = Bookmarks
|
||||||
bookmarks-empty = Pages you bookmark appear here.
|
bookmarks-empty = Pages you bookmark appear here.
|
||||||
|
bookmarks-no-matches = No bookmarks match your search.
|
||||||
|
bookmarks-search-label = Search bookmarks
|
||||||
subscriptions-title = Subscriptions
|
subscriptions-title = Subscriptions
|
||||||
subscriptions-empty = Sites you subscribe to appear here.
|
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
|
## Settings page
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -422,9 +422,9 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(stubDeps.browser.currentPage.value?.url, 'mews:history');
|
expect(stubDeps.browser.currentPage.value?.url, 'mews:history');
|
||||||
|
|
||||||
// A history link navigates like any other link.
|
// A history link navigates like any other link. Rows carry their own
|
||||||
await tester.tap(find.textContaining('Page A'));
|
// actions now, so tap the title itself rather than the row's middle.
|
||||||
await tester.pumpAndSettle();
|
await tapRun(tester, 'Page A');
|
||||||
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html');
|
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html');
|
||||||
|
|
||||||
// Bookmarks and subscriptions get the same treatment.
|
// Bookmarks and subscriptions get the same treatment.
|
||||||
|
|
@ -484,6 +484,102 @@ void main() {
|
||||||
debugDefaultTargetPlatformOverride = null;
|
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',
|
testWidgets('the posts page lists, orders and filters posts',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
const siteA = 'https://a.example/';
|
const siteA = 'https://a.example/';
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,45 @@ void main() {
|
||||||
expect(await db.countHistory(), 2);
|
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 {
|
test('an older database is recreated rather than migrated', () async {
|
||||||
final dir = Directory.systemTemp.createTempSync('mews-schema');
|
final dir = Directory.systemTemp.createTempSync('mews-schema');
|
||||||
addTearDown(() => dir.deleteSync(recursive: true));
|
addTearDown(() => dir.deleteSync(recursive: true));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue