feat: add quotations from marked text, with citations and sharing

This commit is contained in:
randogoth 2026-10-12 10:37:32 +03:00
parent 0e2027c9e2
commit e6e84fdc01
25 changed files with 1558 additions and 4 deletions

View file

@ -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);
}
}

View file

@ -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),

View file

@ -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'),

View file

@ -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';

View file

@ -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'),

View 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}.';
}

View file

@ -148,6 +148,21 @@ class SiteCertificates extends Table {
Set<Column> get primaryKey => {host, port};
}
/// A stretch of text the reader marked on a page and kept, together with the
/// page it came from. The author is carried only when the page's metadata
/// said one, and the site's title when the reader's subscriptions know the
/// site the page belongs to. The column is named [Quotations.quote] because
/// `text` is the drift builder the declaration would otherwise call.
class Quotations extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get pageUrl => text()();
TextColumn get pageTitle => text().nullable()();
TextColumn get author => text().nullable()();
TextColumn get siteTitle => text().nullable()();
TextColumn get quote => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
@DriftDatabase(tables: [
HistoryItems,
Bookmarks,
@ -160,6 +175,7 @@ class SiteCertificates extends Table {
ClientCertificates,
SiteCertificates,
FirstStart,
Quotations,
])
class MewsDatabase extends _$MewsDatabase {
MewsDatabase() : super(_open());
@ -168,7 +184,7 @@ class MewsDatabase extends _$MewsDatabase {
MewsDatabase.test(super.executor);
@override
int get schemaVersion => 6;
int get schemaVersion => 8;
@override
MigrationStrategy get migration => MigrationStrategy(
@ -197,6 +213,12 @@ class MewsDatabase extends _$MewsDatabase {
if (from < 6) {
await m.createTable(firstStart);
}
if (from < 7) {
await m.createTable(quotations);
}
if (from < 8) {
await m.addColumn(quotations, quotations.siteTitle);
}
},
);
@ -717,6 +739,87 @@ class MewsDatabase extends _$MewsDatabase {
return [for (final row in rows) '${row.host}:${row.port}'];
}
// Quotations --------------------------------------------------------------
/// Keeps a marked stretch of text together with the page it came from,
/// and with the site's title when the reader's subscriptions know the site
/// the page belongs to: the site whose posts listed the page, or the page
/// itself when it is the subscribed one.
Future<void> addQuotation({
required String quote,
required String pageUrl,
String? pageTitle,
String? author,
}) async {
into(quotations).insert(QuotationsCompanion.insert(
quote: quote,
pageUrl: pageUrl,
pageTitle: Value(pageTitle),
author: Value(author),
siteTitle: Value(await siteTitleOf(pageUrl)),
));
}
/// The title of the site [pageUrl] belongs to, or null when the reader's
/// subscriptions do not know it. A page the site lists as a post takes the
/// subscribing site's title; a subscribed page is its own site.
Future<String?> siteTitleOf(String pageUrl) async {
final site = alias(subscriptions, 'site');
final throughPosts = await ((select(feedEntries).join([
leftOuterJoin(site, site.url.equalsExp(feedEntries.siteUrl)),
]))
..where(feedEntries.url.equals(pageUrl)))
.get();
if (throughPosts.isNotEmpty) {
return throughPosts.first.readTableOrNull(site)?.title;
}
final own =
await (select(subscriptions)..where((r) => r.url.equals(pageUrl)))
.getSingleOrNull();
return own?.title;
}
Future<void> removeQuotation(int id) =>
(delete(quotations)..where((q) => q.id.equals(id))).go();
/// One quotation, so an action address can act on it.
Future<Quotation?> quotationById(int id) =>
(select(quotations)..where((q) => q.id.equals(id))).getSingleOrNull();
/// Every kept quotation, for the quotations page. [query] narrows by the
/// quoted text, the page's title, its author or its address.
Future<List<Quotation>> quotationsList({
QuotationSort sort = QuotationSort.date,
String? query,
}) async {
final statement = select(quotations)
..orderBy(switch (sort) {
QuotationSort.date => [
// Tiebreak quotations saved within the same clock tick.
(r) => OrderingTerm.desc(r.createdAt),
(r) => OrderingTerm.asc(r.id),
],
QuotationSort.page => [
// A page without a title sorts under its address, so the order
// never depends on a null.
(r) => OrderingTerm.asc(coalesce([r.pageTitle, r.pageUrl])),
(r) => OrderingTerm.desc(r.createdAt),
],
});
final filter = _search(
[quotations.quote, quotations.pageTitle, quotations.author, quotations.pageUrl],
query);
if (filter != null) statement.where((_) => filter);
return statement.get();
}
/// How many quotations are held, for the hub.
Future<int> countQuotations() async {
final total = quotations.id.count();
final query = selectOnly(quotations)..addColumns([total]);
return (await query.getSingle()).read(total) ?? 0;
}
/// Binds a certificate to a host, replacing whatever was bound before.
Future<void> bindCertificate(String host, int port, int certificateId) =>
into(siteCertificates).insert(
@ -743,6 +846,17 @@ enum EntrySort {
};
}
/// How the quotations page is ordered.
enum QuotationSort {
date,
page;
/// Reads the order from an address, falling back to the default rather than
/// refusing a value the reader may have typed.
static QuotationSort parse(String? name) =>
name == 'page' ? QuotationSort.page : QuotationSort.date;
}
/// How widely one tag is used.
class TagUse {
const TagUse({required this.bookmarks, required this.sites});

View file

@ -3579,6 +3579,450 @@ class FirstStartCompanion extends UpdateCompanion<FirstStartData> {
}
}
class $QuotationsTable extends Quotations
with TableInfo<$QuotationsTable, Quotation> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$QuotationsTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<int> id = GeneratedColumn<int>(
'id',
aliasedName,
false,
hasAutoIncrement: true,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'PRIMARY KEY AUTOINCREMENT',
),
);
static const VerificationMeta _pageUrlMeta = const VerificationMeta(
'pageUrl',
);
@override
late final GeneratedColumn<String> pageUrl = GeneratedColumn<String>(
'page_url',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _pageTitleMeta = const VerificationMeta(
'pageTitle',
);
@override
late final GeneratedColumn<String> pageTitle = GeneratedColumn<String>(
'page_title',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _authorMeta = const VerificationMeta('author');
@override
late final GeneratedColumn<String> author = GeneratedColumn<String>(
'author',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _siteTitleMeta = const VerificationMeta(
'siteTitle',
);
@override
late final GeneratedColumn<String> siteTitle = GeneratedColumn<String>(
'site_title',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _quoteMeta = const VerificationMeta('quote');
@override
late final GeneratedColumn<String> quote = GeneratedColumn<String>(
'quote',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: currentDateAndTime,
);
@override
List<GeneratedColumn> get $columns => [
id,
pageUrl,
pageTitle,
author,
siteTitle,
quote,
createdAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'quotations';
@override
VerificationContext validateIntegrity(
Insertable<Quotation> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
}
if (data.containsKey('page_url')) {
context.handle(
_pageUrlMeta,
pageUrl.isAcceptableOrUnknown(data['page_url']!, _pageUrlMeta),
);
} else if (isInserting) {
context.missing(_pageUrlMeta);
}
if (data.containsKey('page_title')) {
context.handle(
_pageTitleMeta,
pageTitle.isAcceptableOrUnknown(data['page_title']!, _pageTitleMeta),
);
}
if (data.containsKey('author')) {
context.handle(
_authorMeta,
author.isAcceptableOrUnknown(data['author']!, _authorMeta),
);
}
if (data.containsKey('site_title')) {
context.handle(
_siteTitleMeta,
siteTitle.isAcceptableOrUnknown(data['site_title']!, _siteTitleMeta),
);
}
if (data.containsKey('quote')) {
context.handle(
_quoteMeta,
quote.isAcceptableOrUnknown(data['quote']!, _quoteMeta),
);
} else if (isInserting) {
context.missing(_quoteMeta);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
Quotation map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return Quotation(
id: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}id'],
)!,
pageUrl: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}page_url'],
)!,
pageTitle: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}page_title'],
),
author: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}author'],
),
siteTitle: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}site_title'],
),
quote: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}quote'],
)!,
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}created_at'],
)!,
);
}
@override
$QuotationsTable createAlias(String alias) {
return $QuotationsTable(attachedDatabase, alias);
}
}
class Quotation extends DataClass implements Insertable<Quotation> {
final int id;
final String pageUrl;
final String? pageTitle;
final String? author;
final String? siteTitle;
final String quote;
final DateTime createdAt;
const Quotation({
required this.id,
required this.pageUrl,
this.pageTitle,
this.author,
this.siteTitle,
required this.quote,
required this.createdAt,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['page_url'] = Variable<String>(pageUrl);
if (!nullToAbsent || pageTitle != null) {
map['page_title'] = Variable<String>(pageTitle);
}
if (!nullToAbsent || author != null) {
map['author'] = Variable<String>(author);
}
if (!nullToAbsent || siteTitle != null) {
map['site_title'] = Variable<String>(siteTitle);
}
map['quote'] = Variable<String>(quote);
map['created_at'] = Variable<DateTime>(createdAt);
return map;
}
QuotationsCompanion toCompanion(bool nullToAbsent) {
return QuotationsCompanion(
id: Value(id),
pageUrl: Value(pageUrl),
pageTitle: pageTitle == null && nullToAbsent
? const Value.absent()
: Value(pageTitle),
author: author == null && nullToAbsent
? const Value.absent()
: Value(author),
siteTitle: siteTitle == null && nullToAbsent
? const Value.absent()
: Value(siteTitle),
quote: Value(quote),
createdAt: Value(createdAt),
);
}
factory Quotation.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return Quotation(
id: serializer.fromJson<int>(json['id']),
pageUrl: serializer.fromJson<String>(json['pageUrl']),
pageTitle: serializer.fromJson<String?>(json['pageTitle']),
author: serializer.fromJson<String?>(json['author']),
siteTitle: serializer.fromJson<String?>(json['siteTitle']),
quote: serializer.fromJson<String>(json['quote']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'pageUrl': serializer.toJson<String>(pageUrl),
'pageTitle': serializer.toJson<String?>(pageTitle),
'author': serializer.toJson<String?>(author),
'siteTitle': serializer.toJson<String?>(siteTitle),
'quote': serializer.toJson<String>(quote),
'createdAt': serializer.toJson<DateTime>(createdAt),
};
}
Quotation copyWith({
int? id,
String? pageUrl,
Value<String?> pageTitle = const Value.absent(),
Value<String?> author = const Value.absent(),
Value<String?> siteTitle = const Value.absent(),
String? quote,
DateTime? createdAt,
}) => Quotation(
id: id ?? this.id,
pageUrl: pageUrl ?? this.pageUrl,
pageTitle: pageTitle.present ? pageTitle.value : this.pageTitle,
author: author.present ? author.value : this.author,
siteTitle: siteTitle.present ? siteTitle.value : this.siteTitle,
quote: quote ?? this.quote,
createdAt: createdAt ?? this.createdAt,
);
Quotation copyWithCompanion(QuotationsCompanion data) {
return Quotation(
id: data.id.present ? data.id.value : this.id,
pageUrl: data.pageUrl.present ? data.pageUrl.value : this.pageUrl,
pageTitle: data.pageTitle.present ? data.pageTitle.value : this.pageTitle,
author: data.author.present ? data.author.value : this.author,
siteTitle: data.siteTitle.present ? data.siteTitle.value : this.siteTitle,
quote: data.quote.present ? data.quote.value : this.quote,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
);
}
@override
String toString() {
return (StringBuffer('Quotation(')
..write('id: $id, ')
..write('pageUrl: $pageUrl, ')
..write('pageTitle: $pageTitle, ')
..write('author: $author, ')
..write('siteTitle: $siteTitle, ')
..write('quote: $quote, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
@override
int get hashCode =>
Object.hash(id, pageUrl, pageTitle, author, siteTitle, quote, createdAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is Quotation &&
other.id == this.id &&
other.pageUrl == this.pageUrl &&
other.pageTitle == this.pageTitle &&
other.author == this.author &&
other.siteTitle == this.siteTitle &&
other.quote == this.quote &&
other.createdAt == this.createdAt);
}
class QuotationsCompanion extends UpdateCompanion<Quotation> {
final Value<int> id;
final Value<String> pageUrl;
final Value<String?> pageTitle;
final Value<String?> author;
final Value<String?> siteTitle;
final Value<String> quote;
final Value<DateTime> createdAt;
const QuotationsCompanion({
this.id = const Value.absent(),
this.pageUrl = const Value.absent(),
this.pageTitle = const Value.absent(),
this.author = const Value.absent(),
this.siteTitle = const Value.absent(),
this.quote = const Value.absent(),
this.createdAt = const Value.absent(),
});
QuotationsCompanion.insert({
this.id = const Value.absent(),
required String pageUrl,
this.pageTitle = const Value.absent(),
this.author = const Value.absent(),
this.siteTitle = const Value.absent(),
required String quote,
this.createdAt = const Value.absent(),
}) : pageUrl = Value(pageUrl),
quote = Value(quote);
static Insertable<Quotation> custom({
Expression<int>? id,
Expression<String>? pageUrl,
Expression<String>? pageTitle,
Expression<String>? author,
Expression<String>? siteTitle,
Expression<String>? quote,
Expression<DateTime>? createdAt,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (pageUrl != null) 'page_url': pageUrl,
if (pageTitle != null) 'page_title': pageTitle,
if (author != null) 'author': author,
if (siteTitle != null) 'site_title': siteTitle,
if (quote != null) 'quote': quote,
if (createdAt != null) 'created_at': createdAt,
});
}
QuotationsCompanion copyWith({
Value<int>? id,
Value<String>? pageUrl,
Value<String?>? pageTitle,
Value<String?>? author,
Value<String?>? siteTitle,
Value<String>? quote,
Value<DateTime>? createdAt,
}) {
return QuotationsCompanion(
id: id ?? this.id,
pageUrl: pageUrl ?? this.pageUrl,
pageTitle: pageTitle ?? this.pageTitle,
author: author ?? this.author,
siteTitle: siteTitle ?? this.siteTitle,
quote: quote ?? this.quote,
createdAt: createdAt ?? this.createdAt,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (pageUrl.present) {
map['page_url'] = Variable<String>(pageUrl.value);
}
if (pageTitle.present) {
map['page_title'] = Variable<String>(pageTitle.value);
}
if (author.present) {
map['author'] = Variable<String>(author.value);
}
if (siteTitle.present) {
map['site_title'] = Variable<String>(siteTitle.value);
}
if (quote.present) {
map['quote'] = Variable<String>(quote.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('QuotationsCompanion(')
..write('id: $id, ')
..write('pageUrl: $pageUrl, ')
..write('pageTitle: $pageTitle, ')
..write('author: $author, ')
..write('siteTitle: $siteTitle, ')
..write('quote: $quote, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
}
abstract class _$MewsDatabase extends GeneratedDatabase {
_$MewsDatabase(QueryExecutor e) : super(e);
$MewsDatabaseManager get managers => $MewsDatabaseManager(this);
@ -3596,6 +4040,7 @@ abstract class _$MewsDatabase extends GeneratedDatabase {
this,
);
late final $FirstStartTable firstStart = $FirstStartTable(this);
late final $QuotationsTable quotations = $QuotationsTable(this);
late final Index historyItemsUrl = Index(
'history_items_url',
'CREATE INDEX history_items_url ON history_items (url)',
@ -3616,6 +4061,7 @@ abstract class _$MewsDatabase extends GeneratedDatabase {
clientCertificates,
siteCertificates,
firstStart,
quotations,
historyItemsUrl,
];
}
@ -5669,6 +6115,235 @@ typedef $$FirstStartTableProcessedTableManager =
FirstStartData,
PrefetchHooks Function()
>;
typedef $$QuotationsTableCreateCompanionBuilder =
QuotationsCompanion Function({
Value<int> id,
required String pageUrl,
Value<String?> pageTitle,
Value<String?> author,
Value<String?> siteTitle,
required String quote,
Value<DateTime> createdAt,
});
typedef $$QuotationsTableUpdateCompanionBuilder =
QuotationsCompanion Function({
Value<int> id,
Value<String> pageUrl,
Value<String?> pageTitle,
Value<String?> author,
Value<String?> siteTitle,
Value<String> quote,
Value<DateTime> createdAt,
});
class $$QuotationsTableFilterComposer
extends Composer<_$MewsDatabase, $QuotationsTable> {
$$QuotationsTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get pageUrl => $composableBuilder(
column: $table.pageUrl,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get pageTitle => $composableBuilder(
column: $table.pageTitle,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get author => $composableBuilder(
column: $table.author,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get siteTitle => $composableBuilder(
column: $table.siteTitle,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get quote => $composableBuilder(
column: $table.quote,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
);
}
class $$QuotationsTableOrderingComposer
extends Composer<_$MewsDatabase, $QuotationsTable> {
$$QuotationsTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get pageUrl => $composableBuilder(
column: $table.pageUrl,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get pageTitle => $composableBuilder(
column: $table.pageTitle,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get author => $composableBuilder(
column: $table.author,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get siteTitle => $composableBuilder(
column: $table.siteTitle,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get quote => $composableBuilder(
column: $table.quote,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
);
}
class $$QuotationsTableAnnotationComposer
extends Composer<_$MewsDatabase, $QuotationsTable> {
$$QuotationsTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get pageUrl =>
$composableBuilder(column: $table.pageUrl, builder: (column) => column);
GeneratedColumn<String> get pageTitle =>
$composableBuilder(column: $table.pageTitle, builder: (column) => column);
GeneratedColumn<String> get author =>
$composableBuilder(column: $table.author, builder: (column) => column);
GeneratedColumn<String> get siteTitle =>
$composableBuilder(column: $table.siteTitle, builder: (column) => column);
GeneratedColumn<String> get quote =>
$composableBuilder(column: $table.quote, builder: (column) => column);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
}
class $$QuotationsTableTableManager
extends
RootTableManager<
_$MewsDatabase,
$QuotationsTable,
Quotation,
$$QuotationsTableFilterComposer,
$$QuotationsTableOrderingComposer,
$$QuotationsTableAnnotationComposer,
$$QuotationsTableCreateCompanionBuilder,
$$QuotationsTableUpdateCompanionBuilder,
(
Quotation,
BaseReferences<_$MewsDatabase, $QuotationsTable, Quotation>,
),
Quotation,
PrefetchHooks Function()
> {
$$QuotationsTableTableManager(_$MewsDatabase db, $QuotationsTable table)
: super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$QuotationsTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$QuotationsTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$QuotationsTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
Value<int> id = const Value.absent(),
Value<String> pageUrl = const Value.absent(),
Value<String?> pageTitle = const Value.absent(),
Value<String?> author = const Value.absent(),
Value<String?> siteTitle = const Value.absent(),
Value<String> quote = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
}) => QuotationsCompanion(
id: id,
pageUrl: pageUrl,
pageTitle: pageTitle,
author: author,
siteTitle: siteTitle,
quote: quote,
createdAt: createdAt,
),
createCompanionCallback:
({
Value<int> id = const Value.absent(),
required String pageUrl,
Value<String?> pageTitle = const Value.absent(),
Value<String?> author = const Value.absent(),
Value<String?> siteTitle = const Value.absent(),
required String quote,
Value<DateTime> createdAt = const Value.absent(),
}) => QuotationsCompanion.insert(
id: id,
pageUrl: pageUrl,
pageTitle: pageTitle,
author: author,
siteTitle: siteTitle,
quote: quote,
createdAt: createdAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$QuotationsTableProcessedTableManager =
ProcessedTableManager<
_$MewsDatabase,
$QuotationsTable,
Quotation,
$$QuotationsTableFilterComposer,
$$QuotationsTableOrderingComposer,
$$QuotationsTableAnnotationComposer,
$$QuotationsTableCreateCompanionBuilder,
$$QuotationsTableUpdateCompanionBuilder,
(Quotation, BaseReferences<_$MewsDatabase, $QuotationsTable, Quotation>),
Quotation,
PrefetchHooks Function()
>;
class $MewsDatabaseManager {
final _$MewsDatabase _db;
@ -5695,4 +6370,6 @@ class $MewsDatabaseManager {
$$SiteCertificatesTableTableManager(_db, _db.siteCertificates);
$$FirstStartTableTableManager get firstStart =>
$$FirstStartTableTableManager(_db, _db.firstStart);
$$QuotationsTableTableManager get quotations =>
$$QuotationsTableTableManager(_db, _db.quotations);
}

View file

@ -36,6 +36,7 @@ class AppDependencies {
Strings? strings,
KeyStore? keys,
Future<PickedPem?> Function(String title)? pickPemFile,
Future<void> Function(String text)? share,
}) : db = database ?? MewsDatabase(),
fetch = fetch ?? Actor.create(FetchHandler.new),
strings = strings ?? Strings() {
@ -61,6 +62,7 @@ class AppDependencies {
keys: this.keys,
strings: this.strings,
pickPemFile: pickPemFile,
share: share,
);
browser = BrowserController(
db: db,

View file

@ -36,6 +36,7 @@ class MewsPageView extends StatefulWidget {
this.onFormChange,
this.fetchImageBytes,
this.scrollController,
this.onSaveQuotation,
this.ownPage = false,
super.key,
});
@ -57,6 +58,10 @@ class MewsPageView extends StatefulWidget {
final Future<Uint8List?> Function(String url)? fetchImageBytes;
final ScrollController? scrollController;
/// Called with text the reader marked and chose to keep as a quotation.
/// Null on a view that cannot keep one, such as a swipe peek.
final void Function(String text)? onSaveQuotation;
/// Whether this is one of the client's own pages. Those are built from the
/// same blocks, but they are interface rather than an article, so they are
/// laid out denser and wider, rows are divided instead of bulleted, and
@ -171,8 +176,14 @@ class _MewsPageViewState extends State<MewsPageView> {
textDirection: widget.page.direction == TextDirection.rtl
? ui.TextDirection.rtl
: ui.TextDirection.ltr,
// Page text is the reader's to copy; forms manage their own selection.
// Page text is the reader's to copy and to keep; forms manage
// their own selection. The toolbar offers to keep the marked
// text as a quotation, so the selection's text is carried here.
child: SelectionArea(
onSelectionChanged: (content) =>
_markedText = content?.plainText,
contextMenuBuilder: (context, selectableRegion) =>
_selectionToolbar(context, selectableRegion),
child: ColoredBox(
color: palette.background,
child: ListView(
@ -201,6 +212,36 @@ class _MewsPageViewState extends State<MewsPageView> {
);
}
/// The text of the selection now on the page, if any, so the toolbar can
/// offer to keep it.
String? _markedText;
/// The selection toolbar: the platform's own buttons, plus the choice to
/// keep the marked text as a quotation when a place to keep it is given.
Widget _selectionToolbar(
BuildContext context, SelectableRegionState selectableRegion) {
final items = selectableRegion.contextMenuButtonItems;
final marked = _markedText?.trim();
if (widget.onSaveQuotation != null && marked != null && marked.isNotEmpty) {
items.insert(
0,
ContextMenuButtonItem(
type: ContextMenuButtonType.custom,
label: StringsScope.of(context).t('quotations-save'),
onPressed: () {
widget.onSaveQuotation!(marked);
selectableRegion.hideToolbar();
selectableRegion.clearSelection();
},
),
);
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: selectableRegion.contextMenuAnchors,
buttonItems: items,
);
}
Widget _notice(String text, double base, MewsPalette palette) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Text(

View file

@ -336,6 +336,14 @@ class _ReaderScreenState extends State<ReaderScreen> {
},
onFormChange: (form, values) =>
deps.browser.narrowWhileTyping(form.action, values),
// The reader's marked text is kept with the page it came from, so
// its citation can name the author when the page did.
onSaveQuotation: (text) => deps.db.addQuotation(
quote: text,
pageUrl: page.url,
pageTitle: page.title,
author: page.author,
),
fetchImageBytes: deps.browser.fetchImageBytes,
scrollController: _scroll,
);

View file

@ -22,6 +22,7 @@ nav-subscriptions = Abos
nav-history = Verlauf
nav-tags = Schlagwörter
nav-settings = Einstellungen
nav-quotations = Zitate
## Hub
@ -45,6 +46,9 @@ count-tags-many = { $count } Schlagwörter
count-unread-none = Keine ungelesenen Beiträge
count-unread-one = 1 ungelesener Beitrag
count-unread-many = { $count } ungelesene Beiträge
count-quotations-none = Noch keine Zitate
count-quotations-one = 1 Zitat
count-quotations-many = { $count } Zitate
## Posts
@ -62,6 +66,21 @@ posts-show-unread = Nur ungelesene
posts-mark-read = Als gelesen markieren
posts-mark-unread = Als ungelesen markieren
## Quotations
# The selection toolbar button that keeps the marked text.
quotations-save = Als Zitat speichern
quotations-title = Zitate
quotations-empty = Text, den du von einer Seite behältst, erscheint hier.
quotations-no-matches = Kein Zitat passt zu deiner Suche.
quotations-search-label = Zitate durchsuchen
quotations-share = Teilen
quotations-remove = Zitat entfernen
# Orders the quotations page by the page they came from.
quotations-sort-page = Seite
# A citation date when the page carried none.
quotations-no-date = o. D.
## Actions
# Link labels used on more than one page.

View file

@ -22,6 +22,7 @@ nav-subscriptions = Subscriptions
nav-history = History
nav-tags = Tags
nav-settings = Settings
nav-quotations = Quotations
## Hub
@ -45,6 +46,9 @@ count-tags-many = { $count } tags
count-unread-none = No unread posts
count-unread-one = 1 unread post
count-unread-many = { $count } unread posts
count-quotations-none = No quotations yet
count-quotations-one = 1 quotation
count-quotations-many = { $count } quotations
## Posts
@ -62,6 +66,21 @@ posts-show-unread = Unread only
posts-mark-read = Mark as read
posts-mark-unread = Mark as unread
## Quotations
# The selection toolbar button that keeps the marked text.
quotations-save = Save as quotation
quotations-title = Quotations
quotations-empty = Text you keep from a page appears here.
quotations-no-matches = No quotations match your search.
quotations-search-label = Search quotations
quotations-share = Share
quotations-remove = Remove quotation
# Orders the quotations page by the page they came from.
quotations-sort-page = Page
# A citation date when the page carried none.
quotations-no-date = n.d.
## Actions
# Link labels used on more than one page.

View file

@ -22,6 +22,7 @@ nav-subscriptions = Suscripciones
nav-history = Historial
nav-tags = Etiquetas
nav-settings = Ajustes
nav-quotations = Citas
## Hub
@ -45,6 +46,9 @@ count-tags-many = { $count } etiquetas
count-unread-none = No hay entradas sin leer
count-unread-one = 1 entrada sin leer
count-unread-many = { $count } entradas sin leer
count-quotations-none = Aún no hay citas
count-quotations-one = 1 cita
count-quotations-many = { $count } citas
## Posts
@ -62,6 +66,21 @@ posts-show-unread = Solo sin leer
posts-mark-read = Marcar como leída
posts-mark-unread = Marcar como no leída
## Quotations
# The selection toolbar button that keeps the marked text.
quotations-save = Guardar como cita
quotations-title = Citas
quotations-empty = El texto que guardes de una página aparece aquí.
quotations-no-matches = Ninguna cita coincide con tu búsqueda.
quotations-search-label = Buscar citas
quotations-share = Compartir
quotations-remove = Eliminar cita
# Orders the quotations page by the page they came from.
quotations-sort-page = Página
# A citation date when the page carried none.
quotations-no-date = s. f.
## Actions
# Link labels used on more than one page.

View file

@ -22,6 +22,7 @@ nav-subscriptions = Abonnements
nav-history = Historique
nav-tags = Étiquettes
nav-settings = Réglages
nav-quotations = Citations
## Hub
@ -45,6 +46,9 @@ count-tags-many = { $count } étiquettes
count-unread-none = Aucun billet non lu
count-unread-one = { $count } billet non lu
count-unread-many = { $count } billets non lus
count-quotations-none = Pas encore de citations
count-quotations-one = 1 citation
count-quotations-many = { $count } citations
## Posts
@ -62,6 +66,21 @@ posts-show-unread = Non lus seulement
posts-mark-read = Marquer comme lu
posts-mark-unread = Marquer comme non lu
## Quotations
# The selection toolbar button that keeps the marked text.
quotations-save = Enregistrer comme citation
quotations-title = Citations
quotations-empty = Le texte que vous gardez d’une page apparaît ici.
quotations-no-matches = Aucune citation ne correspond à votre recherche.
quotations-search-label = Rechercher des citations
quotations-share = Partager
quotations-remove = Supprimer la citation
# Orders the quotations page by the page they came from.
quotations-sort-page = Page
# A citation date when the page carried none.
quotations-no-date = s. d.
## Actions
# Link labels used on more than one page.

View file

@ -22,6 +22,7 @@ nav-subscriptions = הרשמות
nav-history = היסטוריה
nav-tags = תגיות
nav-settings = הגדרות
nav-quotations = ציטוטים
## Hub
@ -49,6 +50,10 @@ count-tags-two = שתי תגיות
count-unread-none = אין רשומות שלא נקראו
count-unread-one = רשומה אחת שלא נקראה
count-unread-many = { $count } רשומות שלא נקראו
count-quotations-none = אין עדיין ציטוטים
count-quotations-one = ציטוט אחד
count-quotations-two = שני ציטוטים
count-quotations-many = { $count } ציטוטים
count-unread-two = שתי רשומות שלא נקראו
## Posts
@ -67,6 +72,21 @@ posts-show-unread = שלא נקראו בלבד
posts-mark-read = סימון כנקראה
posts-mark-unread = סימון כלא נקראה
## Quotations
# The selection toolbar button that keeps the marked text.
quotations-save = שמירה כציטוט
quotations-title = ציטוטים
quotations-empty = טקסט שתשמרו מעמוד יופיע כאן.
quotations-no-matches = אין ציטוטים שתואמים לחיפוש שלכם.
quotations-search-label = חיפוש בציטוטים
quotations-share = שיתוף
quotations-remove = הסרת ציטוט
# Orders the quotations page by the page they came from.
quotations-sort-page = עמוד
# A citation date when the page carried none.
quotations-no-date = לת.ג.
## Actions
# Link labels used on more than one page.

View file

@ -22,6 +22,7 @@ nav-subscriptions = 購読
nav-history = 履歴
nav-tags = タグ
nav-settings = 設定
nav-quotations = 引用
## Hub
@ -45,6 +46,9 @@ count-tags-many = タグ { $count } 件
count-unread-none = 未読の投稿はありません
count-unread-one = 未読の投稿 { $count } 件
count-unread-many = 未読の投稿 { $count } 件
count-quotations-none = 引用はまだありません
count-quotations-one = 1 件の引用
count-quotations-many = { $count } 件の引用
## Posts
@ -62,6 +66,21 @@ posts-show-unread = 未読のみ
posts-mark-read = 既読にする
posts-mark-unread = 未読にする
## Quotations
# The selection toolbar button that keeps the marked text.
quotations-save = 引用として保存
quotations-title = 引用
quotations-empty = ページから保存したテキストがここに表示されます。
quotations-no-matches = 検索に一致する引用はありません。
quotations-search-label = 引用を検索
quotations-share = 共有
quotations-remove = 引用を削除
# Orders the quotations page by the page they came from.
quotations-sort-page = ページ
# A citation date when the page carried none.
quotations-no-date = 年不明
## Actions
# Link labels used on more than one page.

View file

@ -22,6 +22,7 @@ nav-subscriptions = Subscrições
nav-history = Histórico
nav-tags = Etiquetas
nav-settings = Definições
nav-quotations = Citações
## Hub
@ -45,6 +46,9 @@ count-tags-many = { $count } etiquetas
count-unread-none = Não há publicações por ler
count-unread-one = { $count } publicação por ler
count-unread-many = { $count } publicações por ler
count-quotations-none = Ainda não há citações
count-quotations-one = 1 citação
count-quotations-many = { $count } citações
## Posts
@ -62,6 +66,21 @@ posts-show-unread = Apenas por ler
posts-mark-read = Marcar como lida
posts-mark-unread = Marcar como não lida
## Quotations
# The selection toolbar button that keeps the marked text.
quotations-save = Salvar como citação
quotations-title = Citações
quotations-empty = O texto que você guardar de uma página aparece aqui.
quotations-no-matches = Nenhuma citação corresponde à sua busca.
quotations-search-label = Pesquisar citações
quotations-share = Compartilhar
quotations-remove = Remover citação
# Orders the quotations page by the page they came from.
quotations-sort-page = Página
# A citation date when the page carried none.
quotations-no-date = s. d.
## Actions
# Link labels used on more than one page.

View file

@ -22,6 +22,7 @@ nav-subscriptions = Подписки
nav-history = История
nav-tags = Метки
nav-settings = Настройки
nav-quotations = Цитаты
## Hub
@ -49,6 +50,10 @@ count-tags-few = { $count } метки
count-unread-none = Непрочитанных записей нет
count-unread-one = { $count } непрочитанная запись
count-unread-many = { $count } непрочитанных записей
count-quotations-none = Цитат пока нет
count-quotations-one = 1 цитата
count-quotations-few = { $count } цитаты
count-quotations-many = { $count } цитат
count-unread-few = { $count } непрочитанные записи
## Posts
@ -67,6 +72,21 @@ posts-show-unread = Только непрочитанные
posts-mark-read = Отметить прочитанной
posts-mark-unread = Отметить непрочитанной
## Quotations
# The selection toolbar button that keeps the marked text.
quotations-save = Сохранить как цитату
quotations-title = Цитаты
quotations-empty = Текст, который вы сохраните со страницы, появится здесь.
quotations-no-matches = Ни одна цитата не совпадает с вашим поиском.
quotations-search-label = Поиск по цитатам
quotations-share = Поделиться
quotations-remove = Удалить цитату
# Orders the quotations page by the page they came from.
quotations-sort-page = Страница
# A citation date when the page carried none.
quotations-no-date = б. д.
## Actions
# Link labels used on more than one page.

View file

@ -677,6 +677,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.0"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: db40767f130189d53f3ae8a6393c217ff4a492da1baff718a9873679c0c00224
url: "https://pub.dev"
source: hosted
version: "13.3.1"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
shelf:
dependency: transitive
description:
@ -890,6 +906,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.6"
uuid:
dependency: transitive
description:
name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_graphics:
dependency: transitive
description:

View file

@ -25,6 +25,7 @@ dependencies:
crypto: ^3.0.7
file_picker: ^13.1.0
url_launcher: ^6.3.3
share_plus: ^13.3.1
dev_dependencies:
flutter_test:

View file

@ -1185,6 +1185,63 @@ void main() {
await tester.pumpAndSettle();
expect(find.textContaining('1 unread post'), findsOneWidget);
});
testWidgets('marked text can be kept as a quotation', (tester) async {
final source = '''
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>Cats</title>
<meta name="mews-profile" content="0.1" />
<meta name="author" content="A. Author" />
</head>
<body><p>The finest sentence about cats ever written.</p></body>
</html>
''';
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: FakeFetch({'https://example.com/cats.html': source}),
strings: strings,
);
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
await tester.pumpAndSettle();
await stubDeps.browser.open('https://example.com/cats.html');
await tester.pumpAndSettle();
// A long press on the word 'written' selects it and shows the toolbar.
// The press lands on the glyphs: a wrapped line's geometric center can
// sit between the lines, where no word is. The word is on the page's
// second line, where the toolbar fits above it; on the first line the
// toolbar would open past the top of the screen.
final paragraph = tester.renderObject<RenderParagraph>(
find.descendant(
of: find.textContaining('finest sentence'),
matching: find.byType(RichText)));
final plain = paragraph.text.toPlainText();
final word = plain.indexOf('written');
final box = paragraph
.getBoxesForSelection(
TextSelection(baseOffset: word, extentOffset: word + 7))
.first;
final onWord = paragraph.localToGlobal(
Offset((box.left + box.right) / 2, (box.top + box.bottom) / 2));
final gesture = await tester.startGesture(onWord);
await tester.pump(const Duration(milliseconds: 600));
await gesture.up();
await tester.pumpAndSettle();
await tester.tap(find.text('Save as quotation'));
await tester.pumpAndSettle();
// The selection kept its page: address, title and the author the page's
// metadata named.
final kept = await stubDeps.db.quotationsList();
expect(kept, hasLength(1));
expect(kept.single.pageUrl, 'https://example.com/cats.html');
expect(kept.single.pageTitle, 'Cats');
expect(kept.single.author, 'A. Author');
expect(kept.single.quote, contains('written'));
});
}
MewsPage parseSource(String source, String url) =>

View file

@ -231,4 +231,61 @@ void main() {
expect(await db.allClientCertificates(), isEmpty);
expect(await db.bindingFor('capsule.example', 1965), isNull);
});
test('quotations are saved with their page and narrow by a search', () async {
await db.addQuotation(
quote: 'A fine sentence about cats.',
pageUrl: 'https://example.com/cats.html',
pageTitle: 'On cats',
author: 'A. Author',
);
await db.addQuotation(
quote: 'A dog is a dog is a dog.',
pageUrl: 'https://example.com/dogs.html',
);
expect(await db.countQuotations(), 2);
// Saved within the same clock tick, the id tiebreak keeps the order they
// were saved in; the page's title and author ride along.
final kept = await db.quotationsList();
expect(kept.first.pageUrl, 'https://example.com/cats.html');
expect(kept.first.pageTitle, 'On cats');
expect(kept.first.author, 'A. Author');
// A search matches the quoted text, the title, the author or the
// address; it does not have to know which one it hit.
expect((await db.quotationsList(query: 'cats')).length, 1);
expect((await db.quotationsList(query: 'Author')).single.pageUrl,
'https://example.com/cats.html');
expect((await db.quotationsList(query: 'dogs.html')).single.quote,
'A dog is a dog is a dog.');
// Ordered by page, a page without a title sorts under its address.
final byPage = await db.quotationsList(sort: QuotationSort.page);
expect(byPage.first.pageUrl, 'https://example.com/cats.html');
// A quotation of a page the reader's site lists as a post carries the
// site's own title, so its citation can name it.
await db.addSubscription('https://example.com/index.html', 'Signal Bars', null);
await db.addEntries('https://example.com/index.html',
[_entry('https://example.com/2026-10-08-retro.html')]);
await db.addQuotation(
quote: 'The finest retro handsets.',
pageUrl: 'https://example.com/2026-10-08-retro.html',
pageTitle: 'Collecting retro phones',
);
final post = (await db.quotationsList())
.firstWhere((q) => q.pageUrl.contains('retro'));
expect(post.siteTitle, 'Signal Bars');
expect(await db.siteTitleOf('https://example.com/index.html'),
'Signal Bars');
expect(await db.siteTitleOf('https://unknown.example/'), isNull);
// One quotation by its id, for the actions that share or remove it.
final id = kept.last.id;
expect((await db.quotationById(id))?.quote, 'A dog is a dog is a dog.');
await db.removeQuotation(id);
expect(await db.quotationById(id), isNull);
// The retro quotation from the site-title check remains.
expect(await db.countQuotations(), 2);
});
}

View file

@ -0,0 +1,231 @@
import 'dart:io' show File;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show RenderParagraph;
import 'package:flutter_test/flutter_test.dart';
import 'package:mews/application/pages/mews_urls.dart';
import 'package:mews/database/database.dart';
import 'package:mews/l10n/strings.dart';
import 'package:mews/mews/document.dart' show MewsEntry;
import 'package:mews/presentation/app.dart';
import 'fake_fetch.dart';
void main() {
late MewsDatabase db;
late Strings strings;
final shared = <String>[];
setUp(() async {
db = MewsDatabase.test(NativeDatabase.memory());
strings = Strings();
strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync());
});
tearDown(() async {
await db.close();
shared.clear();
});
Future<AppDependencies> pumpApp(WidgetTester tester) async {
final deps = AppDependencies(
database: db,
fetch: FakeFetch(const {}),
strings: strings,
share: (text) async => shared.add(text),
);
await tester.pumpWidget(MewsApp(deps: deps, strings: strings));
await tester.pumpAndSettle();
return deps;
}
/// Taps the run reading [label] inside a rendered paragraph: several links
/// share one paragraph, so the widget's center can miss the glyphs. Pass
/// [within] to pick the paragraph when several carry the label.
Future<void> tapRun(WidgetTester tester, String label, {String? within}) async {
final text = within ?? label;
final paragraphFinder = find.byWidgetPredicate((widget) =>
widget is Text &&
(widget.textSpan?.toPlainText() ?? widget.data ?? '').contains(text));
// The row may sit below the fold; its glyphs must be on screen to tap.
await tester.ensureVisible(paragraphFinder.first);
await tester.pumpAndSettle();
final paragraph = tester.renderObject<RenderParagraph>(
find.descendant(of: paragraphFinder, matching: find.byType(RichText)).first,
);
final plain = paragraph.text.toPlainText();
final start = plain.indexOf(label);
final box = paragraph
.getBoxesForSelection(
TextSelection(baseOffset: start, extentOffset: start + label.length))
.first;
await tester.tapAt(paragraph.localToGlobal(
Offset(box.left + 2, (box.top + box.bottom) / 2)));
await tester.pumpAndSettle();
}
testWidgets('the hub counts the kept quotations', (tester) async {
final deps = await pumpApp(tester);
await deps.browser.restoreLastSession();
await tester.pumpAndSettle();
expect(find.textContaining('No quotations yet'), findsOneWidget);
await db.addQuotation(
quote: 'A line worth keeping.',
pageUrl: 'https://example.com/a.html');
await deps.browser.reload();
await tester.pumpAndSettle();
expect(find.textContaining('1 quotation'), findsOneWidget);
await db.addQuotation(
quote: 'Another line.',
pageUrl: 'https://example.com/b.html');
await deps.browser.reload();
await tester.pumpAndSettle();
expect(find.textContaining('2 quotations'), findsOneWidget);
});
testWidgets('the page lists kept text with its citation and actions',
(tester) async {
await db.addQuotation(
quote: 'A line worth keeping.',
pageUrl: 'https://example.com/a.html',
pageTitle: 'Alpha',
author: 'A. Author');
await db.addQuotation(
quote: 'Another, plainer line.',
pageUrl: 'https://example.com/b.html');
final deps = await pumpApp(tester);
await deps.browser.open(MewsUrls.quotations);
await tester.pumpAndSettle();
expect(find.textContaining('A line worth keeping.'), findsOneWidget);
// The author the page named stands behind its words; a page that gave
// neither author nor title stands behind its address.
expect(find.textContaining('A. Author'), findsOneWidget);
expect(find.textContaining('example.com'), findsWidgets);
expect(find.textContaining('2 quotations'), findsOneWidget);
// The strip and the title both carry the page.
expect(find.text('Quotations'), findsWidgets);
// Each row carries its actions, in the paragraph of its row.
expect(find.textContaining('Share'), findsNWidgets(2));
expect(find.textContaining('Remove quotation'), findsNWidgets(2));
});
testWidgets('a search narrows the page and says so when nothing matches',
(tester) async {
await db.addQuotation(
quote: 'A line worth keeping.',
pageUrl: 'https://example.com/a.html',
author: 'A. Author');
await db.addQuotation(
quote: 'Another, plainer line.',
pageUrl: 'https://example.com/b.html');
final deps = await pumpApp(tester);
await deps.browser.open('mews:quotations?q=plain');
await tester.pumpAndSettle();
expect(find.textContaining('plainer line'), findsOneWidget);
expect(find.textContaining('A line worth keeping.'), findsNothing);
// The search matches the author and the address as well.
await deps.browser.open('mews:quotations?q=Author');
await tester.pumpAndSettle();
expect(find.textContaining('A line worth keeping.'), findsOneWidget);
await deps.browser.open('mews:quotations?q=nowhere');
await tester.pumpAndSettle();
expect(find.textContaining('No quotations match your search'),
findsOneWidget);
});
testWidgets('sorting by page reorders the rows', (tester) async {
await db.addQuotation(
quote: 'A line worth keeping.',
pageUrl: 'https://example.com/zebra.html',
pageTitle: 'Zebra');
await db.addQuotation(
quote: 'Another, plainer line.',
pageUrl: 'https://example.com/ant.html',
pageTitle: 'Ant');
final deps = await pumpApp(tester);
await deps.browser.open('mews:quotations?sort=page');
await tester.pumpAndSettle();
// Ordered by their page, Ant comes before Zebra.
final rows = [
for (final text in ['Ant', 'Zebra'])
tester.getTopLeft(find.textContaining(text).first).dy,
];
expect(rows.first, lessThan(rows.last));
});
testWidgets('a quotation can be removed from its row', (tester) async {
await db.addQuotation(
quote: 'A line worth keeping.',
pageUrl: 'https://example.com/a.html');
final deps = await pumpApp(tester);
await deps.browser.open(MewsUrls.quotations);
await tester.pumpAndSettle();
expect(find.textContaining('A line worth keeping.'), findsOneWidget);
await tapRun(tester, 'Remove quotation');
expect(find.textContaining('A line worth keeping.'), findsNothing);
expect(find.textContaining('No quotations yet'), findsOneWidget);
});
testWidgets('sharing carries the quotation and its citation', (tester) async {
await db.addQuotation(
quote: 'A line worth keeping.',
pageUrl: 'https://example.com/a.html',
pageTitle: 'Alpha',
author: 'A. Author');
final deps = await pumpApp(tester);
await deps.browser.open(MewsUrls.quotations);
await tester.pumpAndSettle();
await tapRun(tester, 'Share');
expect(shared, hasLength(1));
expect(shared.single, contains('A line worth keeping.'));
// APA-shaped: the author, the no-date marker, the title, the site and
// the address.
expect(shared.single,
contains('A. Author (n.d.). Alpha. example.com. https://example.com/a.html.'));
// A page the reader's subscriptions know by name: the citation names
// the site's own title after the article, instead of the host.
await db.addSubscription(
'https://example.com/index.html', 'Signal Bars', null);
await db.addEntries('https://example.com/index.html', [
MewsEntry(
url: 'https://example.com/retro.html',
date: DateTime(2026, 10, 8),
title: 'Collecting retro phones'),
]);
await db.addQuotation(
quote: 'A retro line.',
pageUrl: 'https://example.com/retro.html',
pageTitle: 'Collecting retro phones');
await deps.browser.open(MewsUrls.hub);
await deps.browser.open(MewsUrls.quotations);
await tester.pumpAndSettle();
// The row names the site the text came from, not just the article.
expect(find.textContaining('Signal Bars'), findsOneWidget);
await tapRun(tester, 'Share', within: 'A retro line.');
expect(shared.last, contains('Collecting retro phones (n.d.). Signal Bars. https://example.com/retro.html.'));
// A page without an author leads with its title.
await db.addQuotation(
quote: 'A line without an author.',
pageUrl: 'https://example.com/b.html',
pageTitle: 'Bravo');
await deps.browser.open(MewsUrls.hub);
await deps.browser.open(MewsUrls.quotations);
await tester.pumpAndSettle();
await tapRun(tester, 'Share', within: 'A line without an author.');
expect(shared.last,
contains('Bravo (n.d.). example.com. https://example.com/b.html.'));
});
}