diff --git a/SPEC.md b/SPEC.md index c28377d..6c22adc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -81,12 +81,13 @@ The v1 client presents one reader screen: - A web page that cannot be loaded offers to open in the reader's system web browser, since the web can be shown outside; a page over gemini or spartan has nowhere else to go, so its failures offer only trying again. - On pointer hosts the keyboard drives the reader: Ctrl or Cmd+L focuses the address field with its text selected, Ctrl or Cmd+R reloads the page, and the walk goes back and forward with Alt+Left and Alt+Right, or on a Mac with Cmd+Left, Cmd+Right, Cmd+[ and Cmd+]. Touch devices navigate with gestures instead, and none of these shortcuts are offered there. The links on a page are reachable by keyboard as well: the page is one stop in the focus order, and once it holds the focus, Tab highlights its links in reading order and Enter opens the highlighted one; the page scrolls to keep the highlighted link in view, Shift+Tab walks back, Escape puts the highlight away, and a Tab past either end hands the focus back to the chrome. - A swipe right goes back and a swipe left goes forward through the history stack. On a pointer host the whole empty margin beside the text column accepts the swipe, with a 40 px floor, so the gesture stays clear of selecting text, and the address bar's buttons work as well. On a touch device, where the column leaves no margin, the page surface itself accepts the swipe: a finger that crosses the slop sideways, before the long press that starts a text selection completes, is swiping, while a finger that held still that long keeps its drag for the selection. -- 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:quotations`, `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. - A site that publishes through an Atom feed instead of dated links is read through the feed (spec 6.2). Dated links are the normative mechanism, so the feed is only asked for when the page itself listed nothing, and a feed on another site is refused. Posts are keyed by address, so a post listed both ways is stored once, under the title the page gave it. - 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. - A fresh install comes with the project's directory of sites already bookmarked, so a first session has somewhere to go. It is a bookmark like any other: removing it is the reader's own, and no later start gives it back. - 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. +- The reader can keep marked text as a quotation. Marking text on a page offers, in the selection toolbar, to save it; the kept text is stored with the page it came from, with the author when the page's metadata named one, and with the site's title when the reader's subscriptions know the site the page belongs to. `mews:quotations` lists what is kept, one row per quotation naming the author, the article's title and the site it came from — by its own title when the reader's subscriptions know it, by its host otherwise — with the day it was kept, ordered by that day or by the page it came from, narrowed by a search over the text, the author, the page's title and its address. A row shares its quotation through the system share sheet, together with a citation shaped as APA names a web page: the author first when the page carried one, otherwise the article's title; the year as a no-date marker, since a page declares no date; then the article's title, the site's own title (its host when the subscriptions do not know it), and the address. A row can remove its quotation, and nothing else is offered, since the citation is the record of where the words came from. - Each library page carries a search box that narrows it by title or address as the reader types, a quarter second after they stop. The query rides in the address as any other view state does, but it replaces the step of the walk they are standing on rather than adding one, so a search of eight letters is one step back and not eight; the box keeps what they typed as the page rebuilds under them, and follows the page when the page sets it, as clearing the search does. Submitting the box still works, for a reader who would rather say when to search. Each row carries its own actions: remove a bookmark, leave a site or check it now, forget a page. History lists one row per address, headed by the most recent visit, rather than repeating a page once per visit. Clearing history takes a second step on a page the reader can simply leave, since it cannot be undone. - A change is a link too: following `mews:do/` applies it and rerenders the page in place without adding a history entry. Only a link followed from one of the client's own pages is honored, so a fetched page cannot clear history or change a setting on the reader's behalf (spec 8.1). A form on one of these pages is routed the same way rather than sent to the network. - Our own pages are built from the same blocks as a page but are interface, not an article, so the renderer lays them out differently: wider before they stop growing, denser, rows divided by a line instead of bulleted, section headings quieter, a note under a row set smaller and dimmer, links carried by weight rather than an underline, and a one-field form on a single line. diff --git a/app/lib/application/internal_actions.dart b/app/lib/application/internal_actions.dart index eb09637..ad8b6e4 100644 --- a/app/lib/application/internal_actions.dart +++ b/app/lib/application/internal_actions.dart @@ -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 Function(String title)? pickPemFile, + Future 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 Function() _applyLanguage; @@ -51,6 +55,11 @@ class InternalActions { final KeyStore _keys; final Strings _strings; final Future Function(String title) _pickPemFile; + final Future Function(String text) _share; + + /// Hands [text] to the system share sheet. + static Future _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 _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 _removeQuotation(String? id) async { + final quotation = await _quotation(id); + if (quotation == null) return false; + await _db.removeQuotation(quotation.id); + return true; + } + + Future _quotation(String? id) async { + final parsed = int.tryParse(id ?? ''); + if (parsed == null) return null; + return _db.quotationById(parsed); + } } diff --git a/app/lib/application/internal_pages.dart b/app/lib/application/internal_pages.dart index 7076d04..1d16fd9 100644 --- a/app/lib/application/internal_pages.dart +++ b/app/lib/application/internal_pages.dart @@ -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), diff --git a/app/lib/application/pages/hub_page.dart b/app/lib/application/pages/hub_page.dart index c5d1317..2c2fc88 100644 --- a/app/lib/application/pages/hub_page.dart +++ b/app/lib/application/pages/hub_page.dart @@ -18,6 +18,7 @@ Future 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 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'), diff --git a/app/lib/application/pages/mews_urls.dart b/app/lib/application/pages/mews_urls.dart index 05038f4..6bee520 100644 --- a/app/lib/application/pages/mews_urls.dart +++ b/app/lib/application/pages/mews_urls.dart @@ -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'; diff --git a/app/lib/application/pages/page_blocks.dart b/app/lib/application/pages/page_blocks.dart index 0fe1396..7a5f54f 100644 --- a/app/lib/application/pages/page_blocks.dart +++ b/app/lib/application/pages/page_blocks.dart @@ -27,6 +27,7 @@ typedef NavItem = ({String url, String labelId}); const navItems = [ (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'), diff --git a/app/lib/application/pages/quotations_page.dart b/app/lib/application/pages/quotations_page.dart new file mode 100644 index 0000000..925f79c --- /dev/null +++ b/app/lib/application/pages/quotations_page.dart @@ -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 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 = {}; + 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}.'; +} diff --git a/app/lib/database/database.dart b/app/lib/database/database.dart index 3e0a43d..b67a8ec 100644 --- a/app/lib/database/database.dart +++ b/app/lib/database/database.dart @@ -148,6 +148,21 @@ class SiteCertificates extends Table { Set 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 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 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 removeQuotation(int id) => + (delete(quotations)..where((q) => q.id.equals(id))).go(); + + /// One quotation, so an action address can act on it. + Future 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> 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 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 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}); diff --git a/app/lib/database/database.g.dart b/app/lib/database/database.g.dart index 292bbe1..1c05ecb 100644 --- a/app/lib/database/database.g.dart +++ b/app/lib/database/database.g.dart @@ -3579,6 +3579,450 @@ class FirstStartCompanion extends UpdateCompanion { } } +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 id = GeneratedColumn( + '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 pageUrl = GeneratedColumn( + 'page_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _pageTitleMeta = const VerificationMeta( + 'pageTitle', + ); + @override + late final GeneratedColumn pageTitle = GeneratedColumn( + 'page_title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _authorMeta = const VerificationMeta('author'); + @override + late final GeneratedColumn author = GeneratedColumn( + 'author', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _siteTitleMeta = const VerificationMeta( + 'siteTitle', + ); + @override + late final GeneratedColumn siteTitle = GeneratedColumn( + 'site_title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _quoteMeta = const VerificationMeta('quote'); + @override + late final GeneratedColumn quote = GeneratedColumn( + 'quote', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List 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 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 get $primaryKey => {id}; + @override + Quotation map(Map 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 { + 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 toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['page_url'] = Variable(pageUrl); + if (!nullToAbsent || pageTitle != null) { + map['page_title'] = Variable(pageTitle); + } + if (!nullToAbsent || author != null) { + map['author'] = Variable(author); + } + if (!nullToAbsent || siteTitle != null) { + map['site_title'] = Variable(siteTitle); + } + map['quote'] = Variable(quote); + map['created_at'] = Variable(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 json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Quotation( + id: serializer.fromJson(json['id']), + pageUrl: serializer.fromJson(json['pageUrl']), + pageTitle: serializer.fromJson(json['pageTitle']), + author: serializer.fromJson(json['author']), + siteTitle: serializer.fromJson(json['siteTitle']), + quote: serializer.fromJson(json['quote']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'pageUrl': serializer.toJson(pageUrl), + 'pageTitle': serializer.toJson(pageTitle), + 'author': serializer.toJson(author), + 'siteTitle': serializer.toJson(siteTitle), + 'quote': serializer.toJson(quote), + 'createdAt': serializer.toJson(createdAt), + }; + } + + Quotation copyWith({ + int? id, + String? pageUrl, + Value pageTitle = const Value.absent(), + Value author = const Value.absent(), + Value 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 { + final Value id; + final Value pageUrl; + final Value pageTitle; + final Value author; + final Value siteTitle; + final Value quote; + final Value 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 custom({ + Expression? id, + Expression? pageUrl, + Expression? pageTitle, + Expression? author, + Expression? siteTitle, + Expression? quote, + Expression? 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? id, + Value? pageUrl, + Value? pageTitle, + Value? author, + Value? siteTitle, + Value? quote, + Value? 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 toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (pageUrl.present) { + map['page_url'] = Variable(pageUrl.value); + } + if (pageTitle.present) { + map['page_title'] = Variable(pageTitle.value); + } + if (author.present) { + map['author'] = Variable(author.value); + } + if (siteTitle.present) { + map['site_title'] = Variable(siteTitle.value); + } + if (quote.present) { + map['quote'] = Variable(quote.value); + } + if (createdAt.present) { + map['created_at'] = Variable(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 id, + required String pageUrl, + Value pageTitle, + Value author, + Value siteTitle, + required String quote, + Value createdAt, + }); +typedef $$QuotationsTableUpdateCompanionBuilder = + QuotationsCompanion Function({ + Value id, + Value pageUrl, + Value pageTitle, + Value author, + Value siteTitle, + Value quote, + Value createdAt, + }); + +class $$QuotationsTableFilterComposer + extends Composer<_$MewsDatabase, $QuotationsTable> { + $$QuotationsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get pageUrl => $composableBuilder( + column: $table.pageUrl, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get pageTitle => $composableBuilder( + column: $table.pageTitle, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get author => $composableBuilder( + column: $table.author, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get siteTitle => $composableBuilder( + column: $table.siteTitle, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get quote => $composableBuilder( + column: $table.quote, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters 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 get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get pageUrl => $composableBuilder( + column: $table.pageUrl, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get pageTitle => $composableBuilder( + column: $table.pageTitle, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get author => $composableBuilder( + column: $table.author, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get siteTitle => $composableBuilder( + column: $table.siteTitle, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get quote => $composableBuilder( + column: $table.quote, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings 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 get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get pageUrl => + $composableBuilder(column: $table.pageUrl, builder: (column) => column); + + GeneratedColumn get pageTitle => + $composableBuilder(column: $table.pageTitle, builder: (column) => column); + + GeneratedColumn get author => + $composableBuilder(column: $table.author, builder: (column) => column); + + GeneratedColumn get siteTitle => + $composableBuilder(column: $table.siteTitle, builder: (column) => column); + + GeneratedColumn get quote => + $composableBuilder(column: $table.quote, builder: (column) => column); + + GeneratedColumn 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 id = const Value.absent(), + Value pageUrl = const Value.absent(), + Value pageTitle = const Value.absent(), + Value author = const Value.absent(), + Value siteTitle = const Value.absent(), + Value quote = const Value.absent(), + Value createdAt = const Value.absent(), + }) => QuotationsCompanion( + id: id, + pageUrl: pageUrl, + pageTitle: pageTitle, + author: author, + siteTitle: siteTitle, + quote: quote, + createdAt: createdAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String pageUrl, + Value pageTitle = const Value.absent(), + Value author = const Value.absent(), + Value siteTitle = const Value.absent(), + required String quote, + Value 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); } diff --git a/app/lib/presentation/app.dart b/app/lib/presentation/app.dart index dcc6dff..781f786 100644 --- a/app/lib/presentation/app.dart +++ b/app/lib/presentation/app.dart @@ -36,6 +36,7 @@ class AppDependencies { Strings? strings, KeyStore? keys, Future Function(String title)? pickPemFile, + Future 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, diff --git a/app/lib/presentation/reader/mews_page_view.dart b/app/lib/presentation/reader/mews_page_view.dart index d1ab85e..05ef168 100644 --- a/app/lib/presentation/reader/mews_page_view.dart +++ b/app/lib/presentation/reader/mews_page_view.dart @@ -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 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 { 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 { ); } + /// 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( diff --git a/app/lib/presentation/reader/reader_screen.dart b/app/lib/presentation/reader/reader_screen.dart index 3d553ec..81d7e99 100644 --- a/app/lib/presentation/reader/reader_screen.dart +++ b/app/lib/presentation/reader/reader_screen.dart @@ -336,6 +336,14 @@ class _ReaderScreenState extends State { }, 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, ); diff --git a/app/locales/de/main.ftl b/app/locales/de/main.ftl index 215ecd1..eb187bc 100644 --- a/app/locales/de/main.ftl +++ b/app/locales/de/main.ftl @@ -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. diff --git a/app/locales/en-US/main.ftl b/app/locales/en-US/main.ftl index cfdc52c..7c7a46a 100644 --- a/app/locales/en-US/main.ftl +++ b/app/locales/en-US/main.ftl @@ -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. diff --git a/app/locales/es/main.ftl b/app/locales/es/main.ftl index 505ac86..e6cc278 100644 --- a/app/locales/es/main.ftl +++ b/app/locales/es/main.ftl @@ -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. diff --git a/app/locales/fr/main.ftl b/app/locales/fr/main.ftl index cca9ebf..a80c4c9 100644 --- a/app/locales/fr/main.ftl +++ b/app/locales/fr/main.ftl @@ -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. diff --git a/app/locales/he/main.ftl b/app/locales/he/main.ftl index bffb7dd..9342109 100644 --- a/app/locales/he/main.ftl +++ b/app/locales/he/main.ftl @@ -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. diff --git a/app/locales/ja/main.ftl b/app/locales/ja/main.ftl index 4a114d2..c018d9e 100644 --- a/app/locales/ja/main.ftl +++ b/app/locales/ja/main.ftl @@ -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. diff --git a/app/locales/pt/main.ftl b/app/locales/pt/main.ftl index 351fb76..e2366eb 100644 --- a/app/locales/pt/main.ftl +++ b/app/locales/pt/main.ftl @@ -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. diff --git a/app/locales/ru/main.ftl b/app/locales/ru/main.ftl index 12a4293..22fda07 100644 --- a/app/locales/ru/main.ftl +++ b/app/locales/ru/main.ftl @@ -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. diff --git a/app/pubspec.lock b/app/pubspec.lock index 6c6a73e..8cee3ae 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -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: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 3542a39..ca73855 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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: diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart index 77f906c..ebe4191 100644 --- a/app/test/app_widget_test.dart +++ b/app/test/app_widget_test.dart @@ -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 = ''' + + + Cats + + + +

The finest sentence about cats ever written.

+ +'''; + 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( + 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) => diff --git a/app/test/database_test.dart b/app/test/database_test.dart index 2ff2133..488b989 100644 --- a/app/test/database_test.dart +++ b/app/test/database_test.dart @@ -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); + }); } diff --git a/app/test/quotations_page_test.dart b/app/test/quotations_page_test.dart new file mode 100644 index 0000000..c2293e5 --- /dev/null +++ b/app/test/quotations_page_test.dart @@ -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 = []; + + 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 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 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( + 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.')); + }); +}