library fever_api; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:crypto/crypto.dart'; import 'package:shared_preferences/shared_preferences.dart'; part 'feed.dart'; part 'item.dart'; part 'group.dart'; part 'favicon.dart'; part 'feeds_group.dart'; part 'cache.dart'; 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 { String? apiKey; String? apiUrl; late final APICache cache; /// We use a shared HTTP client for all requests final _httpClient = http.Client(); /// If set, the `newestKnownPost` value will be updated automatically. /// This is used for notifications. SharedPreferences? sharedPrefs; FeverAPI({this.apiKey, this.apiUrl, this.sharedPrefs}) { cache = APICache(this); } static String generateApiKey(String username, String password) { var bytes = utf8.encode('$username:$password'); return md5.convert(bytes).toString(); } /// Checks whether the given API URL is a valid Fever API endpoint static Future isValidAPI(String url) async { try { final response = jsonDecode((await http.post(Uri.parse(url))).body); return response['api_version'] == 3; } catch (e) { 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 isAuthenticated(String url, String key) async { try { final response = jsonDecode( (await http.post(Uri.parse(url), body: {'api_key': key})).body); return response['api_version'] == 3 && response['auth'] == 1; } catch (e) { print(e); return false; } } /// Whether we have an API URL and API key set bool loggedIn() { return apiKey != null && apiUrl != null; } APIRequestBuilder request() { return APIRequestBuilder(this, _httpClient, sharedPrefs); } } class APIRequestBuilder { final FeverAPI api; final SharedPreferences? _sharedPrefs; final http.Client _httpClient; List args = []; Item? _markedItem; ItemMarkType? _markedItemAs; APIRequestBuilder(this.api, this._httpClient, this._sharedPrefs); 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; } APIRequestBuilder withFavicons() { _addArg("favicons"); return this; } /// Mark single item as read, unread, saved, unsaved APIRequestBuilder markItem(Item item, ItemMarkType markAs) { _addArg('mark=item'); _addArg('as=${markAs.name}'); _addArg('id=${item.id}'); _markedItem = item; _markedItemAs = markAs; return this; } /// 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; } APIRequestBuilder withIds(List ids) { _addArg('with_ids=${ids.join(',')}'); return this; } /// Executes the API request and returns the JSON data Future> fetch() async { if (api.apiKey == null || api.apiUrl == null) { throw Exception('API Key or API URL not set'); } var url = generateUrl(); print('Fetching $url'); final response = await _httpClient.post(url, body: {'api_key': api.apiKey}); 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 Future> execute() async { final data = await fetch(); if (data.containsKey('groups')) { List groups = (data['groups'] as List) .map((json) => Group.fromJSON(api, json)) .toList(); for (var group in groups) { api.cache.groups.set(group.id, group); } } if (data.containsKey('feeds')) { List feeds = (data['feeds'] as List) .map((json) => Feed.fromJSON(api, json)) .toList(); for (var feed in feeds) { api.cache.feeds.set(feed.id, feed); } } if (data.containsKey('items')) { var currentHighest = _sharedPrefs?.getInt('newestKnownPost') ?? 0; List items = (data['items'] as List) .map((json) => Item.fromJSON(api, json)) .toList(); for (var item in items) { if (item.id > currentHighest) { currentHighest = item.id; } api.cache.items.set(item.id, item); _sharedPrefs?.setInt('newestKnownPost', currentHighest); } } if (data.containsKey('favicons')) { List favicons = (data['favicons'] as List) .map((json) => Favicon.fromJSON(api, json)) .toList(); for (var favicon in favicons) { api.cache.favicons.set(favicon.id, favicon); } } if (data.containsKey('feeds_groups')) { List feedsGroups = (data['feeds_groups'] as List) .map((json) => FeedsGroup.fromJSON(api, json)) .toList(); api.cache.feedsGroups.clear(); api.cache.feedsGroups.addAll(feedsGroups); } 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!); } return data; } } enum ItemMarkType { read, unread, saved, unsaved, }