2022-12-29 16:11:01 +00:00
|
|
|
library fever_api;
|
|
|
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
|
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import 'package:crypto/crypto.dart';
|
2023-02-01 21:35:16 +00:00
|
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
2022-12-29 16:11:01 +00:00
|
|
|
|
|
|
|
part 'feed.dart';
|
|
|
|
part 'item.dart';
|
|
|
|
part 'group.dart';
|
2023-01-30 07:28:21 +00:00
|
|
|
part 'favicon.dart';
|
2022-12-29 16:11:01 +00:00
|
|
|
part 'feeds_group.dart';
|
2022-12-29 18:16:46 +00:00
|
|
|
part 'cache.dart';
|
2022-12-29 16:11:01 +00:00
|
|
|
part 'exceptions.dart';
|
|
|
|
part 'util.dart';
|
|
|
|
|
|
|
|
/// Basic implementation of the Fever API
|
|
|
|
/// https://github.com/dasmurphy/tinytinyrss-fever-plugin/blob/master/fever-api.md
|
|
|
|
|
|
|
|
class FeverAPI {
|
2022-12-29 18:16:46 +00:00
|
|
|
String? apiKey;
|
|
|
|
String? apiUrl;
|
2022-12-29 16:11:01 +00:00
|
|
|
|
2023-02-01 21:35:16 +00:00
|
|
|
late final APICache cache;
|
|
|
|
|
2022-12-29 16:11:01 +00:00
|
|
|
/// We use a shared HTTP client for all requests
|
|
|
|
final _httpClient = http.Client();
|
|
|
|
|
2023-02-01 21:35:16 +00:00
|
|
|
/// If set, the `newestKnownPost` value will be updated automatically.
|
|
|
|
/// This is used for notifications.
|
|
|
|
SharedPreferences? sharedPrefs;
|
2022-12-29 18:16:46 +00:00
|
|
|
|
2023-02-01 21:35:16 +00:00
|
|
|
FeverAPI({this.apiKey, this.apiUrl, this.sharedPrefs}) {
|
2022-12-29 18:16:46 +00:00
|
|
|
cache = APICache(this);
|
|
|
|
}
|
2022-12-29 16:11:01 +00:00
|
|
|
|
|
|
|
static String generateApiKey(String username, String password) {
|
|
|
|
var bytes = utf8.encode('$username:$password');
|
|
|
|
return md5.convert(bytes).toString();
|
|
|
|
}
|
|
|
|
|
2022-12-29 20:05:14 +00:00
|
|
|
/// Checks whether the given API URL is a valid Fever API endpoint
|
|
|
|
static Future<bool> isValidAPI(String url) async {
|
|
|
|
try {
|
|
|
|
final response = jsonDecode((await http.post(Uri.parse(url))).body);
|
|
|
|
return response['api_version'] == 3;
|
2022-12-30 16:10:09 +00:00
|
|
|
} catch (e) {
|
2022-12-29 20:05:14 +00:00
|
|
|
print(e);
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Checks whether the given API URL is a valid Fever API endpoint and the given API key is authorized to access it
|
|
|
|
static Future<bool> isAuthenticated(String url, String key) async {
|
|
|
|
try {
|
2022-12-30 16:10:09 +00:00
|
|
|
final response = jsonDecode(
|
|
|
|
(await http.post(Uri.parse(url), body: {'api_key': key})).body);
|
2022-12-29 20:05:14 +00:00
|
|
|
return response['api_version'] == 3 && response['auth'] == 1;
|
2022-12-30 16:10:09 +00:00
|
|
|
} catch (e) {
|
2022-12-29 20:05:14 +00:00
|
|
|
print(e);
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Whether we have an API URL and API key set
|
2022-12-29 18:16:46 +00:00
|
|
|
bool loggedIn() {
|
|
|
|
return apiKey != null && apiUrl != null;
|
|
|
|
}
|
|
|
|
|
2022-12-29 16:11:01 +00:00
|
|
|
APIRequestBuilder request() {
|
2023-02-01 21:35:16 +00:00
|
|
|
return APIRequestBuilder(this, _httpClient, sharedPrefs);
|
2022-12-29 16:11:01 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class APIRequestBuilder {
|
|
|
|
final FeverAPI api;
|
2023-02-01 21:35:16 +00:00
|
|
|
final SharedPreferences? _sharedPrefs;
|
2022-12-29 16:11:01 +00:00
|
|
|
final http.Client _httpClient;
|
|
|
|
|
|
|
|
List<String> args = [];
|
2022-12-30 16:10:09 +00:00
|
|
|
Item? _markedItem;
|
|
|
|
ItemMarkType? _markedItemAs;
|
2022-12-29 16:11:01 +00:00
|
|
|
|
2023-02-01 21:35:16 +00:00
|
|
|
APIRequestBuilder(this.api, this._httpClient, this._sharedPrefs);
|
2022-12-29 16:11:01 +00:00
|
|
|
|
|
|
|
void _addArg(String arg) {
|
|
|
|
if (!args.contains(arg)) args.add(arg);
|
|
|
|
}
|
|
|
|
|
|
|
|
Uri generateUrl() {
|
|
|
|
return Uri.parse('${api.apiUrl}?api&${args.join('&')}');
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Include items in response
|
|
|
|
APIRequestBuilder withItems() {
|
|
|
|
_addArg('items');
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Include feeds in response
|
|
|
|
APIRequestBuilder withFeeds() {
|
|
|
|
_addArg('feeds');
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Include groups in response
|
|
|
|
APIRequestBuilder withGroups() {
|
|
|
|
_addArg('groups');
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
2023-01-30 07:28:21 +00:00
|
|
|
APIRequestBuilder withFavicons() {
|
|
|
|
_addArg("favicons");
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
2023-01-23 07:47:10 +00:00
|
|
|
/// Mark single item as read, unread, saved, unsaved
|
2022-12-30 16:10:09 +00:00
|
|
|
APIRequestBuilder markItem(Item item, ItemMarkType markAs) {
|
|
|
|
_addArg('mark=item');
|
|
|
|
_addArg('as=${markAs.name}');
|
|
|
|
_addArg('id=${item.id}');
|
|
|
|
_markedItem = item;
|
|
|
|
_markedItemAs = markAs;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
2023-01-23 07:47:10 +00:00
|
|
|
/// Use the `since_id` argument with the highest id of locally cached items to
|
|
|
|
/// request 50 additional items. Repeat until the items array in the response
|
|
|
|
/// is empty.
|
|
|
|
APIRequestBuilder sinceId(int id) {
|
|
|
|
_addArg('since_id=$id');
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Use the `max_id` argument with the lowest id of locally cached items (or
|
|
|
|
/// 0 initially) to request 50 previous items. Repeat until the items array
|
|
|
|
/// in the response is empty.
|
|
|
|
APIRequestBuilder maxId(int id) {
|
|
|
|
_addArg('max_id=$id');
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
2022-12-29 16:11:01 +00:00
|
|
|
/// Executes the API request and returns the JSON data
|
|
|
|
Future<Map<String, dynamic>> fetch() async {
|
2022-12-30 16:10:09 +00:00
|
|
|
if (api.apiKey == null || api.apiUrl == null) {
|
|
|
|
throw Exception('API Key or API URL not set');
|
|
|
|
}
|
2022-12-29 18:16:46 +00:00
|
|
|
|
2023-02-02 07:02:25 +00:00
|
|
|
var url = generateUrl();
|
|
|
|
print('Fetching $url');
|
|
|
|
|
|
|
|
final response = await _httpClient.post(url, body: {'api_key': api.apiKey});
|
2022-12-29 16:11:01 +00:00
|
|
|
final data = jsonDecode((response).body);
|
|
|
|
if (data['auth'] == 0) throw UnauthenticatedException(data['auth']);
|
|
|
|
return data;
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Executes the API request and populates the local cache with the response data
|
2023-01-23 07:47:10 +00:00
|
|
|
Future<Map<String, dynamic>> execute() async {
|
2022-12-29 16:11:01 +00:00
|
|
|
final data = await fetch();
|
|
|
|
|
|
|
|
if (data.containsKey('groups')) {
|
2022-12-29 18:16:46 +00:00
|
|
|
List<Group> groups = (data['groups'] as List<dynamic>)
|
2022-12-30 16:10:09 +00:00
|
|
|
.map((json) => Group.fromJSON(api, json))
|
|
|
|
.toList();
|
2022-12-29 18:16:46 +00:00
|
|
|
|
|
|
|
for (var group in groups) {
|
|
|
|
api.cache.groups.set(group.id, group);
|
|
|
|
}
|
2022-12-29 16:11:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (data.containsKey('feeds')) {
|
2022-12-29 18:16:46 +00:00
|
|
|
List<Feed> feeds = (data['feeds'] as List<dynamic>)
|
2022-12-30 16:10:09 +00:00
|
|
|
.map((json) => Feed.fromJSON(api, json))
|
|
|
|
.toList();
|
2022-12-29 18:16:46 +00:00
|
|
|
|
|
|
|
for (var feed in feeds) {
|
|
|
|
api.cache.feeds.set(feed.id, feed);
|
|
|
|
}
|
2022-12-29 16:11:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (data.containsKey('items')) {
|
2023-02-01 21:35:16 +00:00
|
|
|
var currentHighest = _sharedPrefs?.getInt('newestKnownPost') ?? 0;
|
|
|
|
|
2022-12-29 18:16:46 +00:00
|
|
|
List<Item> items = (data['items'] as List<dynamic>)
|
2022-12-30 16:10:09 +00:00
|
|
|
.map((json) => Item.fromJSON(api, json))
|
|
|
|
.toList();
|
2022-12-29 18:16:46 +00:00
|
|
|
|
|
|
|
for (var item in items) {
|
2023-02-01 21:35:16 +00:00
|
|
|
if (item.id > currentHighest) {
|
|
|
|
currentHighest = item.id;
|
|
|
|
}
|
|
|
|
|
2022-12-29 18:16:46 +00:00
|
|
|
api.cache.items.set(item.id, item);
|
2023-02-01 21:35:16 +00:00
|
|
|
_sharedPrefs?.setInt('newestKnownPost', currentHighest);
|
2022-12-29 18:16:46 +00:00
|
|
|
}
|
2022-12-29 16:11:01 +00:00
|
|
|
}
|
|
|
|
|
2023-01-30 07:28:21 +00:00
|
|
|
if (data.containsKey('favicons')) {
|
|
|
|
List<Favicon> favicons = (data['favicons'] as List<dynamic>)
|
|
|
|
.map((json) => Favicon.fromJSON(api, json))
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
for (var favicon in favicons) {
|
|
|
|
api.cache.favicons.set(favicon.id, favicon);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-12-29 16:11:01 +00:00
|
|
|
if (data.containsKey('feeds_groups')) {
|
2022-12-29 18:16:46 +00:00
|
|
|
List<FeedsGroup> feedsGroups = (data['feeds_groups'] as List<dynamic>)
|
2022-12-30 16:10:09 +00:00
|
|
|
.map((json) => FeedsGroup.fromJSON(api, json))
|
|
|
|
.toList();
|
2022-12-29 16:11:01 +00:00
|
|
|
|
2022-12-29 18:16:46 +00:00
|
|
|
api.cache.feedsGroups.clear();
|
|
|
|
api.cache.feedsGroups.addAll(feedsGroups);
|
|
|
|
}
|
2022-12-30 16:10:09 +00:00
|
|
|
|
|
|
|
if (_markedItem != null && _markedItemAs != null) {
|
|
|
|
switch (_markedItemAs!) {
|
|
|
|
case ItemMarkType.read:
|
|
|
|
_markedItem!.isRead = true;
|
|
|
|
break;
|
|
|
|
case ItemMarkType.unread:
|
|
|
|
_markedItem!.isRead = false;
|
|
|
|
break;
|
|
|
|
case ItemMarkType.saved:
|
|
|
|
_markedItem!.isSaved = true;
|
|
|
|
break;
|
|
|
|
case ItemMarkType.unsaved:
|
|
|
|
_markedItem!.isSaved = false;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
api.cache.items.set(_markedItem!.id, _markedItem!);
|
|
|
|
}
|
2023-01-23 07:47:10 +00:00
|
|
|
|
|
|
|
return data;
|
2022-12-29 16:11:01 +00:00
|
|
|
}
|
|
|
|
}
|
2022-12-30 16:10:09 +00:00
|
|
|
|
|
|
|
enum ItemMarkType {
|
|
|
|
read,
|
|
|
|
unread,
|
|
|
|
saved,
|
|
|
|
unsaved,
|
|
|
|
}
|