feat: tag bookmarks and sites, and browse the library by tag
This commit is contained in:
parent
5cd053c8a9
commit
b0b86ef5b1
13 changed files with 445 additions and 21 deletions
1
SPEC.md
1
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.
|
- 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.
|
||||||
|
- 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.
|
- 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`.
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ class InternalActions {
|
||||||
'unsubscribe' => _withUrl(args, _subscriptions.remove),
|
'unsubscribe' => _withUrl(args, _subscriptions.remove),
|
||||||
'forget-page' => _withUrl(args, _db.forgetPage),
|
'forget-page' => _withUrl(args, _db.forgetPage),
|
||||||
'clear-history' => _clearHistory(),
|
'clear-history' => _clearHistory(),
|
||||||
|
'add-tag' => _changeTag(args, add: true),
|
||||||
|
'remove-tag' => _changeTag(args, add: false),
|
||||||
|
'delete-tag' => _deleteTag(args['tag']),
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -64,6 +67,32 @@ class InternalActions {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds or removes one tag. An empty name is refused, so submitting an
|
||||||
|
/// empty box does nothing.
|
||||||
|
Future<bool> _changeTag(Map<String, String> 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<bool> _deleteTag(String? tag) async {
|
||||||
|
if (tag == null || tag.isEmpty) return false;
|
||||||
|
await _db.deleteTag(tag);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> _clearHistory() async {
|
Future<bool> _clearHistory() async {
|
||||||
await _db.clearHistory();
|
await _db.clearHistory();
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import 'pages/hub_page.dart';
|
||||||
import 'pages/mews_urls.dart';
|
import 'pages/mews_urls.dart';
|
||||||
import 'pages/posts_page.dart';
|
import 'pages/posts_page.dart';
|
||||||
import 'pages/settings_page.dart';
|
import 'pages/settings_page.dart';
|
||||||
|
import 'pages/tags_page.dart';
|
||||||
import 'pages/subscriptions_page.dart';
|
import 'pages/subscriptions_page.dart';
|
||||||
|
|
||||||
/// Builds the client's own pages as real [MewsPage]s: they render like any
|
/// 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),
|
'bookmarks' => buildBookmarksPage(uri, db: _db, strings: _strings),
|
||||||
'subscriptions' => buildSubscriptionsPage(uri, db: _db, strings: _strings),
|
'subscriptions' => buildSubscriptionsPage(uri, db: _db, strings: _strings),
|
||||||
'history' => buildHistoryPage(uri, db: _db, strings: _strings),
|
'history' => buildHistoryPage(uri, db: _db, strings: _strings),
|
||||||
|
'tags' => buildTagsPage(uri, db: _db, strings: _strings),
|
||||||
'settings' =>
|
'settings' =>
|
||||||
buildSettingsPage(uri, settings: _settings, strings: _strings),
|
buildSettingsPage(uri, settings: _settings, strings: _strings),
|
||||||
_ => Future.value(null),
|
_ => Future.value(null),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import '../../l10n/strings.dart';
|
||||||
import '../../mews/document.dart';
|
import '../../mews/document.dart';
|
||||||
import 'mews_urls.dart';
|
import 'mews_urls.dart';
|
||||||
import 'page_blocks.dart';
|
import 'page_blocks.dart';
|
||||||
|
import 'tags_page.dart';
|
||||||
|
|
||||||
Future<MewsPage> buildBookmarksPage(
|
Future<MewsPage> buildBookmarksPage(
|
||||||
Uri url, {
|
Uri url, {
|
||||||
|
|
@ -10,7 +11,10 @@ Future<MewsPage> buildBookmarksPage(
|
||||||
required Strings strings,
|
required Strings strings,
|
||||||
}) async {
|
}) async {
|
||||||
final query = url.queryParameters['q'] ?? '';
|
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');
|
final title = strings.t('bookmarks-title');
|
||||||
|
|
||||||
return internalPage(
|
return internalPage(
|
||||||
|
|
@ -18,16 +22,30 @@ Future<MewsPage> buildBookmarksPage(
|
||||||
title: title,
|
title: title,
|
||||||
body: [
|
body: [
|
||||||
...chrome(title: title, current: url, strings: strings),
|
...chrome(title: title, current: url, strings: strings),
|
||||||
searchForm(
|
textForm(
|
||||||
action: MewsUrls.bookmarks,
|
action: MewsUrls.bookmarks,
|
||||||
label: strings.t('bookmarks-search-label'),
|
label: strings.t('bookmarks-search-label'),
|
||||||
submitLabel: strings.t('action-search'),
|
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}),
|
MewsLink(MewsUrls.page(url, {'q': null}),
|
||||||
[MewsText(strings.t('action-clear-search'))]),
|
[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)
|
if (bookmarks.isEmpty)
|
||||||
ParagraphBlock([
|
ParagraphBlock([
|
||||||
|
|
@ -41,10 +59,16 @@ 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),
|
||||||
|
chips: [
|
||||||
|
for (final tag in tags[bookmark.url] ?? const <String>[])
|
||||||
|
action(MewsUrls.page(url, {'tag': tag, 'q': null}), tag),
|
||||||
|
],
|
||||||
actions: [
|
actions: [
|
||||||
action(
|
action(
|
||||||
MewsUrls.action('remove-bookmark', {'url': bookmark.url}),
|
MewsUrls.action('remove-bookmark', {'url': bookmark.url}),
|
||||||
strings.t('action-remove-bookmark')),
|
strings.t('action-remove-bookmark')),
|
||||||
|
action(tagsOfItemUrl(TagKind.bookmark, bookmark.url),
|
||||||
|
strings.t('action-edit-tags')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,12 @@ Future<MewsPage> buildHistoryPage(
|
||||||
title: title,
|
title: title,
|
||||||
body: [
|
body: [
|
||||||
...chrome(title: title, current: url, strings: strings),
|
...chrome(title: title, current: url, strings: strings),
|
||||||
searchForm(
|
textForm(
|
||||||
action: MewsUrls.history,
|
action: MewsUrls.history,
|
||||||
label: strings.t('history-search-label'),
|
label: strings.t('history-search-label'),
|
||||||
submitLabel: strings.t('action-search'),
|
submitLabel: strings.t('action-search'),
|
||||||
query: query,
|
field: 'q',
|
||||||
|
value: query,
|
||||||
),
|
),
|
||||||
// Clearing history cannot be undone, so it takes a second step rather
|
// Clearing history cannot be undone, so it takes a second step rather
|
||||||
// than a dialog: another page the reader can simply leave.
|
// than a dialog: another page the reader can simply leave.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ Future<MewsPage> buildHubPage(
|
||||||
final sites = await db.countSubscriptions();
|
final sites = await db.countSubscriptions();
|
||||||
final visits = await db.countHistory();
|
final visits = await db.countHistory();
|
||||||
final unread = await db.unreadCount();
|
final unread = await db.unreadCount();
|
||||||
|
final tags = await db.tagCounts();
|
||||||
|
|
||||||
return internalPage(
|
return internalPage(
|
||||||
url: url,
|
url: url,
|
||||||
|
|
@ -57,6 +58,14 @@ Future<MewsPage> buildHubPage(
|
||||||
one: 'count-pages-one',
|
one: 'count-pages-one',
|
||||||
many: 'count-pages-many'),
|
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')),
|
itemRow(href: MewsUrls.settings, label: strings.t('nav-settings')),
|
||||||
]),
|
]),
|
||||||
ParagraphBlock([
|
ParagraphBlock([
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ abstract final class MewsUrls {
|
||||||
static const bookmarks = 'mews:bookmarks';
|
static const bookmarks = 'mews:bookmarks';
|
||||||
static const subscriptions = 'mews:subscriptions';
|
static const subscriptions = 'mews:subscriptions';
|
||||||
static const history = 'mews:history';
|
static const history = 'mews:history';
|
||||||
|
static const tags = 'mews:tags';
|
||||||
static const settings = 'mews:settings';
|
static const settings = 'mews:settings';
|
||||||
|
|
||||||
/// Whether [url] belongs to the internal scheme.
|
/// Whether [url] belongs to the internal scheme.
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ MewsListItem itemRow({
|
||||||
String? date,
|
String? date,
|
||||||
String? note,
|
String? note,
|
||||||
bool strong = false,
|
bool strong = false,
|
||||||
|
List<Choice> chips = const [],
|
||||||
List<Choice> actions = const [],
|
List<Choice> actions = const [],
|
||||||
}) {
|
}) {
|
||||||
final text = date == null ? label : '$date $label';
|
final text = date == null ? label : '$date $label';
|
||||||
|
|
@ -83,6 +84,11 @@ MewsListItem itemRow({
|
||||||
const MewsLineBreak(),
|
const MewsLineBreak(),
|
||||||
MewsEmphasis(EmphasisStyle.emphasis, [MewsText(note)]),
|
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) ...[
|
if (actions.isNotEmpty) ...[
|
||||||
const MewsLineBreak(),
|
const MewsLineBreak(),
|
||||||
..._separated(
|
..._separated(
|
||||||
|
|
@ -139,18 +145,20 @@ ListBlock choiceRow(List<Choice> options) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A one-field search box that submits back to its own page, turning what the
|
/// A one-field form that submits back into the mews scheme, turning what the
|
||||||
/// reader typed into a query string. [keep] carries the parameters the submit
|
/// reader typed into one query parameter named [field]. [keep] carries the
|
||||||
/// must not drop as hidden fields, the way an HTML form does.
|
/// 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
|
/// 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
|
/// state by position, so a block appearing above it would reset what the
|
||||||
/// reader typed.
|
/// reader typed.
|
||||||
FormBlock searchForm({
|
FormBlock textForm({
|
||||||
required String action,
|
required String action,
|
||||||
|
required String field,
|
||||||
required String label,
|
required String label,
|
||||||
required String submitLabel,
|
required String submitLabel,
|
||||||
required String query,
|
String value = '',
|
||||||
Map<String, String> keep = const {},
|
Map<String, String> keep = const {},
|
||||||
}) {
|
}) {
|
||||||
return FormBlock(
|
return FormBlock(
|
||||||
|
|
@ -158,7 +166,7 @@ FormBlock searchForm({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
submitLabel: submitLabel,
|
submitLabel: submitLabel,
|
||||||
fields: [
|
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)
|
for (final kept in keep.entries)
|
||||||
MewsHiddenField(name: kept.key, value: kept.value),
|
MewsHiddenField(name: kept.key, value: kept.value),
|
||||||
MewsSubmitField(reset: false, label: submitLabel),
|
MewsSubmitField(reset: false, label: submitLabel),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import '../../l10n/strings.dart';
|
||||||
import '../../mews/document.dart';
|
import '../../mews/document.dart';
|
||||||
import 'mews_urls.dart';
|
import 'mews_urls.dart';
|
||||||
import 'page_blocks.dart';
|
import 'page_blocks.dart';
|
||||||
|
import 'tags_page.dart';
|
||||||
|
|
||||||
Future<MewsPage> buildSubscriptionsPage(
|
Future<MewsPage> buildSubscriptionsPage(
|
||||||
Uri url, {
|
Uri url, {
|
||||||
|
|
@ -10,7 +11,10 @@ Future<MewsPage> buildSubscriptionsPage(
|
||||||
required Strings strings,
|
required Strings strings,
|
||||||
}) async {
|
}) async {
|
||||||
final query = url.queryParameters['q'] ?? '';
|
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 unread = await db.unreadCounts();
|
||||||
final title = strings.t('subscriptions-title');
|
final title = strings.t('subscriptions-title');
|
||||||
|
|
||||||
|
|
@ -19,16 +23,30 @@ Future<MewsPage> buildSubscriptionsPage(
|
||||||
title: title,
|
title: title,
|
||||||
body: [
|
body: [
|
||||||
...chrome(title: title, current: url, strings: strings),
|
...chrome(title: title, current: url, strings: strings),
|
||||||
searchForm(
|
textForm(
|
||||||
action: MewsUrls.subscriptions,
|
action: MewsUrls.subscriptions,
|
||||||
label: strings.t('subscriptions-search-label'),
|
label: strings.t('subscriptions-search-label'),
|
||||||
submitLabel: strings.t('action-search'),
|
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}),
|
MewsLink(MewsUrls.page(url, {'q': null}),
|
||||||
[MewsText(strings.t('action-clear-search'))]),
|
[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)
|
if (sites.isEmpty)
|
||||||
ParagraphBlock([
|
ParagraphBlock([
|
||||||
|
|
@ -43,12 +61,18 @@ Future<MewsPage> buildSubscriptionsPage(
|
||||||
href: site.url,
|
href: site.url,
|
||||||
label: site.title ?? site.url,
|
label: site.title ?? site.url,
|
||||||
note: _checkedNote(strings, site, unread[site.url] ?? 0),
|
note: _checkedNote(strings, site, unread[site.url] ?? 0),
|
||||||
|
chips: [
|
||||||
|
for (final tag in tags[site.url] ?? const <String>[])
|
||||||
|
action(MewsUrls.page(url, {'tag': tag, 'q': null}), tag),
|
||||||
|
],
|
||||||
actions: [
|
actions: [
|
||||||
action(
|
action(
|
||||||
MewsUrls.action('refresh-subscription', {'url': site.url}),
|
MewsUrls.action('refresh-subscription', {'url': site.url}),
|
||||||
strings.t('action-refresh')),
|
strings.t('action-refresh')),
|
||||||
action(MewsUrls.action('unsubscribe', {'url': site.url}),
|
action(MewsUrls.action('unsubscribe', {'url': site.url}),
|
||||||
strings.t('action-unsubscribe')),
|
strings.t('action-unsubscribe')),
|
||||||
|
action(tagsOfItemUrl(TagKind.subscription, site.url),
|
||||||
|
strings.t('action-edit-tags')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
|
||||||
157
app/lib/application/pages/tags_page.dart
Normal file
157
app/lib/application/pages/tags_page.dart
Normal file
|
|
@ -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<MewsPage> 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<MewsPage> _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<MewsInline> _uses(Strings strings, String tag, TagUse use) {
|
||||||
|
final links = <MewsInline>[];
|
||||||
|
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<MewsPage> _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,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -218,7 +218,7 @@ class MewsDatabase extends _$MewsDatabase {
|
||||||
|
|
||||||
/// Bookmarks in the order they were added, newest first. [query] narrows by
|
/// Bookmarks in the order they were added, newest first. [query] narrows by
|
||||||
/// title or address.
|
/// title or address.
|
||||||
Future<List<Bookmark>> bookmarksByDate({String? query}) async {
|
Future<List<Bookmark>> bookmarksByDate({String? query, String? tag}) async {
|
||||||
final statement = (select(bookmarks)
|
final statement = (select(bookmarks)
|
||||||
..orderBy([
|
..orderBy([
|
||||||
(r) => OrderingTerm.desc(r.createdAt),
|
(r) => OrderingTerm.desc(r.createdAt),
|
||||||
|
|
@ -227,6 +227,9 @@ class MewsDatabase extends _$MewsDatabase {
|
||||||
]));
|
]));
|
||||||
final filter = _search([bookmarks.url, bookmarks.title], query);
|
final filter = _search([bookmarks.url, bookmarks.title], query);
|
||||||
if (filter != null) statement.where((_) => filter);
|
if (filter != null) statement.where((_) => filter);
|
||||||
|
if (tag != null && tag.isNotEmpty) {
|
||||||
|
statement.where((r) => r.url.isInQuery(_taggedUrls(TagKind.bookmark, tag)));
|
||||||
|
}
|
||||||
return statement.get();
|
return statement.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,7 +264,7 @@ class MewsDatabase extends _$MewsDatabase {
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Subscribed sites, newest first. [query] narrows by title or address.
|
/// Subscribed sites, newest first. [query] narrows by title or address.
|
||||||
Future<List<Subscription>> allSubscriptions({String? query}) async {
|
Future<List<Subscription>> allSubscriptions({String? query, String? tag}) async {
|
||||||
final statement = (select(subscriptions)
|
final statement = (select(subscriptions)
|
||||||
..orderBy([
|
..orderBy([
|
||||||
(r) => OrderingTerm.desc(r.createdAt),
|
(r) => OrderingTerm.desc(r.createdAt),
|
||||||
|
|
@ -269,6 +272,10 @@ class MewsDatabase extends _$MewsDatabase {
|
||||||
]));
|
]));
|
||||||
final filter = _search([subscriptions.url, subscriptions.title], query);
|
final filter = _search([subscriptions.url, subscriptions.title], query);
|
||||||
if (filter != null) statement.where((_) => filter);
|
if (filter != null) statement.where((_) => filter);
|
||||||
|
if (tag != null && tag.isNotEmpty) {
|
||||||
|
statement
|
||||||
|
.where((r) => r.url.isInQuery(_taggedUrls(TagKind.subscription, tag)));
|
||||||
|
}
|
||||||
return statement.get();
|
return statement.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -405,6 +412,75 @@ class MewsDatabase extends _$MewsDatabase {
|
||||||
|
|
||||||
// Tags ---------------------------------------------------------------------
|
// Tags ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The addresses of one kind carrying [tag], for filtering a library page.
|
||||||
|
JoinedSelectStatement<ItemTags, ItemTag> _taggedUrls(
|
||||||
|
TagKind kind, String tag) {
|
||||||
|
return selectOnly(itemTags)
|
||||||
|
..addColumns([itemTags.url])
|
||||||
|
..where(itemTags.kind.equalsValue(kind) & itemTags.tag.equals(tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every tag in use with how many bookmarks and sites carry it. A tag exists
|
||||||
|
/// only while something carries it, so this is the whole list of tags.
|
||||||
|
Future<Map<String, TagUse>> tagCounts() async {
|
||||||
|
final total = itemTags.url.count();
|
||||||
|
final query = selectOnly(itemTags)
|
||||||
|
..addColumns([itemTags.tag, itemTags.kind, total])
|
||||||
|
..groupBy([itemTags.tag, itemTags.kind])
|
||||||
|
..orderBy([OrderingTerm.asc(itemTags.tag)]);
|
||||||
|
|
||||||
|
final counts = <String, TagUse>{};
|
||||||
|
for (final row in await query.get()) {
|
||||||
|
final tag = row.read(itemTags.tag)!;
|
||||||
|
final kind = itemTags.kind.converter.fromSql(row.read(itemTags.kind)!);
|
||||||
|
final count = row.read(total) ?? 0;
|
||||||
|
final held = counts[tag] ?? const TagUse(bookmarks: 0, sites: 0);
|
||||||
|
counts[tag] = switch (kind) {
|
||||||
|
TagKind.bookmark => TagUse(bookmarks: count, sites: held.sites),
|
||||||
|
TagKind.subscription =>
|
||||||
|
TagUse(bookmarks: held.bookmarks, sites: count),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tags on several items at once, so a list of rows costs one query.
|
||||||
|
Future<Map<String, List<String>>> tagsOfMany(
|
||||||
|
TagKind kind, List<String> urls) async {
|
||||||
|
if (urls.isEmpty) return const {};
|
||||||
|
final rows = await (select(itemTags)
|
||||||
|
..where((r) => r.kind.equalsValue(kind) & r.url.isIn(urls))
|
||||||
|
..orderBy([
|
||||||
|
(r) => OrderingTerm.asc(r.createdAt),
|
||||||
|
(r) => OrderingTerm.asc(r.tag),
|
||||||
|
]))
|
||||||
|
.get();
|
||||||
|
final tags = <String, List<String>>{};
|
||||||
|
for (final row in rows) {
|
||||||
|
(tags[row.url] ??= []).add(row.tag);
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a tag from everything carrying it.
|
||||||
|
Future<void> deleteTag(String tag) =>
|
||||||
|
(delete(itemTags)..where((r) => r.tag.equals(tag))).go();
|
||||||
|
|
||||||
|
/// What the reader calls one bookmark or site, for titling its tags page.
|
||||||
|
Future<String?> itemTitle(TagKind kind, String url) async {
|
||||||
|
switch (kind) {
|
||||||
|
case TagKind.bookmark:
|
||||||
|
final row = await (select(bookmarks)..where((r) => r.url.equals(url)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row?.title;
|
||||||
|
case TagKind.subscription:
|
||||||
|
final row =
|
||||||
|
await (select(subscriptions)..where((r) => r.url.equals(url)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row?.title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The tags on one item, in the order they were added.
|
/// The tags on one item, in the order they were added.
|
||||||
Future<List<String>> tagsOf(TagKind kind, String url) async {
|
Future<List<String>> tagsOf(TagKind kind, String url) async {
|
||||||
final query = (select(itemTags)
|
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.
|
/// One page the reader visited, collapsed to its latest visit.
|
||||||
class VisitedPage {
|
class VisitedPage {
|
||||||
const VisitedPage({
|
const VisitedPage({
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ nav-posts = Posts
|
||||||
nav-bookmarks = Bookmarks
|
nav-bookmarks = Bookmarks
|
||||||
nav-subscriptions = Subscriptions
|
nav-subscriptions = Subscriptions
|
||||||
nav-history = History
|
nav-history = History
|
||||||
|
nav-tags = Tags
|
||||||
nav-settings = Settings
|
nav-settings = Settings
|
||||||
|
|
||||||
## Hub
|
## Hub
|
||||||
|
|
@ -38,6 +39,9 @@ count-sites-many = { $count } sites
|
||||||
count-pages-none = No pages yet
|
count-pages-none = No pages yet
|
||||||
count-pages-one = 1 page
|
count-pages-one = 1 page
|
||||||
count-pages-many = { $count } pages
|
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-none = No unread posts
|
||||||
count-unread-one = 1 unread post
|
count-unread-one = 1 unread post
|
||||||
count-unread-many = { $count } unread posts
|
count-unread-many = { $count } unread posts
|
||||||
|
|
@ -66,6 +70,7 @@ action-search = Search
|
||||||
action-clear-search = Clear search
|
action-clear-search = Clear search
|
||||||
action-remove-bookmark = Remove bookmark
|
action-remove-bookmark = Remove bookmark
|
||||||
action-unsubscribe = Stop subscribing
|
action-unsubscribe = Stop subscribing
|
||||||
|
action-edit-tags = Edit tags
|
||||||
|
|
||||||
## Library pages
|
## Library pages
|
||||||
|
|
||||||
|
|
@ -91,6 +96,25 @@ subscriptions-search-label = Search sites
|
||||||
subscriptions-checked = Last checked { $date }
|
subscriptions-checked = Last checked { $date }
|
||||||
subscriptions-never-checked = Not checked yet
|
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
|
||||||
|
|
||||||
settings-page-title = Settings
|
settings-page-title = Settings
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@ void main() {
|
||||||
db = MewsDatabase.test(NativeDatabase.memory());
|
db = MewsDatabase.test(NativeDatabase.memory());
|
||||||
strings = Strings();
|
strings = Strings();
|
||||||
strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync());
|
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 {
|
tearDown(() async {
|
||||||
|
|
@ -530,6 +531,63 @@ void main() {
|
||||||
expect(deps.bookmarks.isBookmarked('https://a.example/retro.html'), isFalse);
|
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 {
|
testWidgets('clearing history takes a second step', (tester) async {
|
||||||
await pumpApp(tester);
|
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');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue