253 lines
9.1 KiB
Dart
253 lines
9.1 KiB
Dart
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);
|
|
}
|
|
}
|