feat: add quotations from marked text, with citations and sharing
This commit is contained in:
parent
0e2027c9e2
commit
e6e84fdc01
25 changed files with 1558 additions and 4 deletions
|
|
@ -2,6 +2,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
|
||||
|
||||
import '../database/database.dart';
|
||||
import '../l10n/strings.dart';
|
||||
|
|
@ -11,6 +12,7 @@ import 'controllers/reader_settings_controller.dart'
|
|||
import 'controllers/subscriptions_controller.dart';
|
||||
import 'key_store.dart';
|
||||
import 'pages/mews_urls.dart';
|
||||
import 'pages/quotations_page.dart' show citationFor;
|
||||
import 'pem.dart';
|
||||
|
||||
/// Applies the change an action address encodes, e.g. mews:do/set-theme.
|
||||
|
|
@ -34,6 +36,7 @@ class InternalActions {
|
|||
required KeyStore keys,
|
||||
required Strings strings,
|
||||
Future<PickedPem?> Function(String title)? pickPemFile,
|
||||
Future<void> Function(String text)? share,
|
||||
}) : _db = db,
|
||||
_applyLanguage = applyLanguage,
|
||||
_settings = settings,
|
||||
|
|
@ -41,7 +44,8 @@ class InternalActions {
|
|||
_subscriptions = subscriptions,
|
||||
_keys = keys,
|
||||
_strings = strings,
|
||||
_pickPemFile = pickPemFile ?? _pickPemFromFileSystem;
|
||||
_pickPemFile = pickPemFile ?? _pickPemFromFileSystem,
|
||||
_share = share ?? _shareThroughSystem;
|
||||
|
||||
final MewsDatabase _db;
|
||||
final Future<void> Function() _applyLanguage;
|
||||
|
|
@ -51,6 +55,11 @@ class InternalActions {
|
|||
final KeyStore _keys;
|
||||
final Strings _strings;
|
||||
final Future<PickedPem?> Function(String title) _pickPemFile;
|
||||
final Future<void> Function(String text) _share;
|
||||
|
||||
/// Hands [text] to the system share sheet.
|
||||
static Future<void> _shareThroughSystem(String text) =>
|
||||
SharePlus.instance.share(ShareParams(text: text));
|
||||
|
||||
/// Opens the platform file picker for one PEM file, titled with what the
|
||||
/// step asks for. A single file may hold both the certificate and its key,
|
||||
|
|
@ -92,6 +101,8 @@ class InternalActions {
|
|||
'import-certificate' => _importCertificate(),
|
||||
'rename-certificate' => _renameCertificate(args),
|
||||
'delete-certificate' => _deleteCertificate(args),
|
||||
'share-quotation' => _shareQuotation(args['id']),
|
||||
'remove-quotation' => _removeQuotation(args['id']),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
|
@ -216,4 +227,27 @@ class InternalActions {
|
|||
await _subscriptions.refreshOne(siteUrl);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Shares the quotation with its citation. Nothing on the page changes, so
|
||||
/// the reader stays exactly where they were.
|
||||
Future<bool> _shareQuotation(String? id) async {
|
||||
final quotation = await _quotation(id);
|
||||
if (quotation == null) return false;
|
||||
await _share('${quotation.quote}\n\n'
|
||||
'${citationFor(quotation, _strings)}');
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _removeQuotation(String? id) async {
|
||||
final quotation = await _quotation(id);
|
||||
if (quotation == null) return false;
|
||||
await _db.removeQuotation(quotation.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<Quotation?> _quotation(String? id) async {
|
||||
final parsed = int.tryParse(id ?? '');
|
||||
if (parsed == null) return null;
|
||||
return _db.quotationById(parsed);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'pages/history_page.dart';
|
|||
import 'pages/hub_page.dart';
|
||||
import 'pages/mews_urls.dart';
|
||||
import 'pages/posts_page.dart';
|
||||
import 'pages/quotations_page.dart';
|
||||
import 'pages/settings_page.dart';
|
||||
import 'pages/tags_page.dart';
|
||||
import 'pages/subscriptions_page.dart';
|
||||
|
|
@ -47,6 +48,8 @@ class InternalPages {
|
|||
db: _db,
|
||||
strings: _strings,
|
||||
refreshing: _subscriptions.refreshing.value),
|
||||
'quotations' =>
|
||||
buildQuotationsPage(uri, db: _db, strings: _strings),
|
||||
'bookmarks' => buildBookmarksPage(uri, db: _db, strings: _strings),
|
||||
'subscriptions' => buildSubscriptionsPage(uri, db: _db, strings: _strings),
|
||||
'history' => buildHistoryPage(uri, db: _db, strings: _strings),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ Future<MewsPage> buildHubPage(
|
|||
final sites = await db.countSubscriptions();
|
||||
final visits = await db.countHistory();
|
||||
final unread = await db.unreadCount();
|
||||
final quotations = await db.countQuotations();
|
||||
final tags = await db.tagCounts();
|
||||
|
||||
return internalPage(
|
||||
|
|
@ -35,6 +36,14 @@ Future<MewsPage> buildHubPage(
|
|||
one: 'count-unread-one',
|
||||
many: 'count-unread-many'),
|
||||
),
|
||||
itemRow(
|
||||
href: MewsUrls.quotations,
|
||||
label: strings.t('nav-quotations'),
|
||||
note: countText(strings, quotations,
|
||||
none: 'count-quotations-none',
|
||||
one: 'count-quotations-one',
|
||||
many: 'count-quotations-many'),
|
||||
),
|
||||
itemRow(
|
||||
href: MewsUrls.bookmarks,
|
||||
label: strings.t('nav-bookmarks'),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ abstract final class MewsUrls {
|
|||
|
||||
static const hub = 'mews:hub';
|
||||
static const posts = 'mews:posts';
|
||||
static const quotations = 'mews:quotations';
|
||||
static const bookmarks = 'mews:bookmarks';
|
||||
static const subscriptions = 'mews:subscriptions';
|
||||
static const history = 'mews:history';
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ typedef NavItem = ({String url, String labelId});
|
|||
const navItems = <NavItem>[
|
||||
(url: MewsUrls.hub, labelId: 'nav-hub'),
|
||||
(url: MewsUrls.posts, labelId: 'nav-posts'),
|
||||
(url: MewsUrls.quotations, labelId: 'nav-quotations'),
|
||||
(url: MewsUrls.bookmarks, labelId: 'nav-bookmarks'),
|
||||
(url: MewsUrls.subscriptions, labelId: 'nav-subscriptions'),
|
||||
(url: MewsUrls.history, labelId: 'nav-history'),
|
||||
|
|
|
|||
139
app/lib/application/pages/quotations_page.dart
Normal file
139
app/lib/application/pages/quotations_page.dart
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import '../../database/database.dart';
|
||||
import '../../l10n/strings.dart';
|
||||
import '../../mews/document.dart';
|
||||
import 'mews_urls.dart';
|
||||
import 'page_blocks.dart';
|
||||
|
||||
/// Every text the reader kept from a page, with the page it came from. The
|
||||
/// page mirrors the posts page: a searchable list ordered by the day the
|
||||
/// text was kept or by the page it came from, one row per quotation, each
|
||||
/// row carrying the citation's author when the page named one.
|
||||
Future<MewsPage> buildQuotationsPage(
|
||||
Uri url, {
|
||||
required MewsDatabase db,
|
||||
required Strings strings,
|
||||
}) async {
|
||||
final query = url.queryParameters['q'] ?? '';
|
||||
final sort = QuotationSort.parse(url.queryParameters['sort']);
|
||||
final quotations = await db.quotationsList(sort: sort, query: query);
|
||||
final total = await db.countQuotations();
|
||||
final title = strings.t('quotations-title');
|
||||
|
||||
return internalPage(
|
||||
url: url,
|
||||
title: title,
|
||||
strings: strings,
|
||||
body: [
|
||||
...chrome(title: title, current: url, strings: strings),
|
||||
textForm(
|
||||
action: MewsUrls.quotations,
|
||||
label: strings.t('quotations-search-label'),
|
||||
submitLabel: strings.t('action-search'),
|
||||
field: 'q',
|
||||
value: query,
|
||||
),
|
||||
...linkLine([
|
||||
if (query.isNotEmpty)
|
||||
action(MewsUrls.page(url, {'q': null}),
|
||||
strings.t('action-clear-search')),
|
||||
]),
|
||||
...choiceLine(
|
||||
label: strings.t('posts-sort-label'),
|
||||
options: [
|
||||
for (final option in const [
|
||||
(QuotationSort.date, 'posts-sort-date'),
|
||||
(QuotationSort.page, 'quotations-sort-page'),
|
||||
])
|
||||
(
|
||||
href: MewsUrls.page(url, {'sort': option.$1.name}),
|
||||
label: strings.t(option.$2),
|
||||
current: option.$1 == sort,
|
||||
),
|
||||
],
|
||||
),
|
||||
const RuleBlock(),
|
||||
ParagraphBlock([
|
||||
MewsEmphasis(EmphasisStyle.emphasis, [
|
||||
MewsText(countText(strings, total,
|
||||
none: 'count-quotations-none',
|
||||
one: 'count-quotations-one',
|
||||
many: 'count-quotations-many')),
|
||||
]),
|
||||
]),
|
||||
if (quotations.isEmpty)
|
||||
ParagraphBlock([
|
||||
MewsText(strings.t(
|
||||
query.isEmpty ? 'quotations-empty' : 'quotations-no-matches')),
|
||||
])
|
||||
else
|
||||
itemList([
|
||||
for (final quotation in quotations)
|
||||
itemRow(
|
||||
href: quotation.pageUrl,
|
||||
label: quotation.quote,
|
||||
date: dayText(quotation.createdAt),
|
||||
// The row names the page the text came from, not just the
|
||||
// text: the author and the article's title when they are
|
||||
// known, and the site by its own title when the reader's
|
||||
// subscriptions know it, otherwise by its host.
|
||||
note: _rowNote(quotation),
|
||||
actions: [
|
||||
action(
|
||||
MewsUrls.action('share-quotation', {'id': '${quotation.id}'}),
|
||||
strings.t('quotations-share')),
|
||||
action(
|
||||
MewsUrls.action(
|
||||
'remove-quotation', {'id': '${quotation.id}'}),
|
||||
strings.t('quotations-remove')),
|
||||
],
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Who and what the row names behind a quotation: the author and the
|
||||
/// article's title when they are known, and the site the page came from by
|
||||
/// its own title, or by its host when the subscriptions do not know it.
|
||||
/// Nothing is named twice.
|
||||
String? _rowNote(Quotation quotation) {
|
||||
final host = Uri.tryParse(quotation.pageUrl)?.host;
|
||||
final source = (quotation.siteTitle?.trim().isNotEmpty ?? false)
|
||||
? quotation.siteTitle!.trim()
|
||||
: (host != null && host.isNotEmpty ? host : quotation.pageUrl);
|
||||
final named = <String>{};
|
||||
final parts = [
|
||||
for (final part in [
|
||||
quotation.author?.trim(),
|
||||
quotation.pageTitle?.trim(),
|
||||
source,
|
||||
])
|
||||
if (part != null && part.isNotEmpty && named.add(part)) part,
|
||||
];
|
||||
return parts.isEmpty ? null : parts.join(actionSeparator);
|
||||
}
|
||||
|
||||
/// The citation a quotation travels with, shaped as APA names a web page:
|
||||
/// the author first when the page's metadata carried one, otherwise the
|
||||
/// article's title; the year as a no-date marker, since a page declares no
|
||||
/// publication date; then the article's title, the site's own title and the
|
||||
/// address. The site is named by its title when the reader's subscriptions
|
||||
/// know it, and by its host when they do not; nothing is named twice.
|
||||
String citationFor(Quotation quotation, Strings strings) {
|
||||
final noDate = strings.t('quotations-no-date');
|
||||
final uri = Uri.tryParse(quotation.pageUrl);
|
||||
final host =
|
||||
uri != null && uri.host.isNotEmpty ? uri.host : quotation.pageUrl;
|
||||
final author = quotation.author?.trim();
|
||||
final title = quotation.pageTitle?.trim();
|
||||
final site = (quotation.siteTitle?.trim().isNotEmpty ?? false)
|
||||
? quotation.siteTitle!.trim()
|
||||
: host;
|
||||
final lead = (author != null && author.isNotEmpty) ? author : title ?? site;
|
||||
final named = [
|
||||
if (lead != title && title != null && title.isNotEmpty) title,
|
||||
if (site != lead && site != title) site,
|
||||
];
|
||||
if (named.isEmpty) return '$lead ($noDate). ${quotation.pageUrl}.';
|
||||
return '$lead ($noDate). ${named.join('. ')}. ${quotation.pageUrl}.';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue