the-mews/app/lib/application/internal_actions.dart

254 lines
9.1 KiB
Dart
Raw Normal View History

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';
import 'controllers/bookmarks_controller.dart';
import 'controllers/reader_settings_controller.dart'
show ReaderSettingsController;
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.
/// Returns whether anything changed, so the navigator can skip a rerender that
/// would show the same page.
///
/// An unknown verb or a missing argument does nothing and reports nothing: the
/// only links carrying these addresses are the ones the client builds, so a
/// miss is a bug rather than something to explain to the reader.
/// A PEM file the reader picked, or null when the pick was cancelled. [name]
/// names the identity after its file, which is what the reader chose.
typedef PickedPem = ({String name, String content});
class InternalActions {
InternalActions({
required MewsDatabase db,
required Future<void> Function() applyLanguage,
required ReaderSettingsController settings,
required BookmarksController bookmarks,
required SubscriptionsController subscriptions,
required KeyStore keys,
required Strings strings,
Future<PickedPem?> Function(String title)? pickPemFile,
Future<void> Function(String text)? share,
}) : _db = db,
_applyLanguage = applyLanguage,
_settings = settings,
_bookmarks = bookmarks,
_subscriptions = subscriptions,
_keys = keys,
_strings = strings,
_pickPemFile = pickPemFile ?? _pickPemFromFileSystem,
_share = share ?? _shareThroughSystem;
final MewsDatabase _db;
final Future<void> Function() _applyLanguage;
final ReaderSettingsController _settings;
final BookmarksController _bookmarks;
final SubscriptionsController _subscriptions;
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,
/// or either one alone. A cancelled pick comes back as nothing.
static Future<PickedPem?> _pickPemFromFileSystem(String title) async {
final files = await FilePicker.pickFiles(
dialogTitle: title,
type: FileType.custom,
allowedExtensions: const ['pem', 'crt', 'cer', 'key']);
final path = files.singleOrNull?.path;
if (path == null) return null;
return (
name: p.basenameWithoutExtension(path),
content: await File(path).readAsString(),
);
}
Future<bool> apply(String url) async {
final verb = MewsUrls.verbOf(url);
if (verb == null) return false;
final args = Uri.parse(url).queryParameters;
return switch (verb) {
'set-font' => _settings.setFont(args['value'] ?? ''),
'set-size' => _setSize(args['step']),
'set-theme' => _settings.setThemeMode(args['value'] ?? ''),
'set-colors' => _settings.setVariant(args['value'] ?? ''),
'set-language' => _setLanguage(args['value']),
'mark-read' => _markRead(args, read: true),
'mark-unread' => _markRead(args, read: false),
'refresh-subscriptions' => _refreshAll(),
'refresh-subscription' => _refreshOne(args['url']),
'remove-bookmark' => _withUrl(args, _bookmarks.remove),
'unsubscribe' => _withUrl(args, _subscriptions.remove),
'forget-page' => _withUrl(args, _db.forgetPage),
'clear-history' => _clearHistory(),
'add-tag' => _changeTag(args, add: true),
'remove-tag' => _changeTag(args, add: false),
'delete-tag' => _deleteTag(args['tag']),
'import-certificate' => _importCertificate(),
'rename-certificate' => _renameCertificate(args),
'delete-certificate' => _deleteCertificate(args),
'share-quotation' => _shareQuotation(args['id']),
'remove-quotation' => _removeQuotation(args['id']),
_ => false,
};
}
/// Reads a certificate and its key from picked PEM files. The first pick
/// may hold both; when it holds only one, the other is asked for again, by
/// name, so a key picked on its own is not silently dropped. A cancelled
/// pick imports nothing.
Future<bool> _importCertificate() async {
final first = await _pickPemFile(_strings.t('certificates-pick-title'));
if (first == null) return false;
String? cert = certificateOf(first.content);
String? key = privateKeyOf(first.content);
if (cert == null || key == null) {
final asking = cert == null
? _strings.t('certificates-pick-cert-title')
: _strings.t('certificates-pick-key-title');
final second = await _pickPemFile(asking);
if (second == null) return false;
cert ??= certificateOf(second.content);
key ??= privateKeyOf(second.content);
}
if (cert == null || key == null) return false;
final name = first.name.trim();
final id = await _db.addClientCertificate(
name.isEmpty ? _strings.t('certificates-default-name') : name, cert);
await _keys.save(id, key);
return true;
}
Future<bool> _renameCertificate(Map<String, String> args) async {
final id = int.tryParse(args['id'] ?? '');
final name = args['name']?.trim();
if (id == null || name == null || name.isEmpty) return false;
await _db.renameClientCertificate(id, name);
return true;
}
Future<bool> _deleteCertificate(Map<String, String> args) async {
final id = int.tryParse(args['id'] ?? '');
if (id == null) return false;
await _keys.delete(id);
await _db.deleteClientCertificate(id);
return true;
}
/// Changing the language has to load its messages before the page is built
/// again, or the rerender would still read the old ones.
Future<bool> _setLanguage(String? tag) async {
if (tag == null || tag.isEmpty) return false;
if (!await _settings.setLanguage(tag)) return false;
await _applyLanguage();
return true;
}
Future<bool> _setSize(String? step) => switch (step) {
'up' => _settings.increaseSize(),
'down' => _settings.decreaseSize(),
_ => Future.value(false),
};
/// Runs [change] on the address the action names, if it names one.
Future<bool> _withUrl(
Map<String, String> args, Future<void> Function(String) change) async {
final url = args['url'];
if (url == null || url.isEmpty) return false;
await change(url);
return true;
}
/// Adds or removes one tag. An empty name is refused, so submitting an
/// empty box does nothing.
Future<bool> _changeTag(Map<String, String> args, {required bool add}) async {
final kind = switch (args['kind']) {
'bookmark' => TagKind.bookmark,
'subscription' => TagKind.subscription,
_ => null,
};
final url = args['url'];
final tag = args['tag']?.trim();
if (kind == null || url == null || url.isEmpty) return false;
if (tag == null || tag.isEmpty) return false;
if (add) {
await _db.addTag(kind, url, tag);
} else {
await _db.removeTag(kind, url, tag);
}
return true;
}
Future<bool> _deleteTag(String? tag) async {
if (tag == null || tag.isEmpty) return false;
await _db.deleteTag(tag);
return true;
}
Future<bool> _clearHistory() async {
await _db.clearHistory();
return true;
}
Future<bool> _markRead(Map<String, String> args, {required bool read}) async {
final url = args['url'];
if (url == null || url.isEmpty) return false;
final site = args['site'];
if (read) {
await _subscriptions.markRead(url, siteUrl: site);
} else {
await _subscriptions.markUnread(url, siteUrl: site);
}
return true;
}
/// The check redraws the page itself as it runs, so the caller need not.
Future<bool> _refreshAll() async {
await _subscriptions.refreshAll();
return false;
}
Future<bool> _refreshOne(String? siteUrl) async {
if (siteUrl == null || siteUrl.isEmpty) return false;
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);
}
}