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

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