Add OPML feed import and export support
5057c55 parent: 3e88e98 modified
app/lib/src/api/client.dart +31 -14 | @@ -18,7 +18,7 @@ class ApiException implements Exception { | ||
| 18 | 18 | /// loopback. That is why this class is a thin wrapper rather than a session. |
| 19 | 19 | class GleanClient { |
| 20 | 20 | GleanClient({required this.baseUrl, this.token = '', http.Client? client}) |
| 21 | - : _client = client ?? http.Client(); | |
| 21 | + : _client = client ?? http.Client(); | |
| 22 | 22 | |
| 23 | 23 | final String baseUrl; |
| 24 | 24 | |
| @@ -61,15 +61,19 @@ class GleanClient { | ||
| 61 | 61 | _decode(await _client.get(_uri(path, q), headers: _headers)); |
| 62 | 62 | |
| 63 | 63 | Future<dynamic> _post(String path, [Map<String, String>? form]) async => |
| 64 | - _decode(await _client.post(_uri(path), | |
| 65 | - headers: _headers, body: form ?? const {})); | |
| 64 | + _decode( | |
| 65 | + await _client.post( | |
| 66 | + _uri(path), | |
| 67 | + headers: _headers, | |
| 68 | + body: form ?? const {}, | |
| 69 | + ), | |
| 70 | + ); | |
| 66 | 71 | |
| 67 | 72 | // --- feeds --- |
| 68 | 73 | |
| 69 | - Future<List<Feed>> feeds() async => | |
| 70 | - ((await _get('/feeds')) as List) | |
| 71 | - .map((e) => Feed.fromJson(e as Map<String, dynamic>)) | |
| 72 | - .toList(); | |
| 74 | + Future<List<Feed>> feeds() async => ((await _get('/feeds')) as List) | |
| 75 | + .map((e) => Feed.fromJson(e as Map<String, dynamic>)) | |
| 76 | + .toList(); | |
| 73 | 77 | |
| 74 | 78 | Future<int> unreadCount() async => |
| 75 | 79 | ((await _get('/unread')) as Map<String, dynamic>)['count'] as int? ?? 0; |
| @@ -82,9 +86,21 @@ class GleanClient { | ||
| 82 | 86 | return (j['added'] as num?)?.toInt() ?? 0; |
| 83 | 87 | } |
| 84 | 88 | |
| 85 | - Future<void> removeFeed(String feedUrl) async => | |
| 86 | - _decode(await _client.delete(_uri('/feeds', {'url': feedUrl}), | |
| 87 | - headers: _headers)); | |
| 89 | + Future<void> removeFeed(String feedUrl) async => _decode( | |
| 90 | + await _client.delete(_uri('/feeds', {'url': feedUrl}), headers: _headers), | |
| 91 | + ); | |
| 92 | + | |
| 93 | + Future<String> exportOpml() async { | |
| 94 | + final res = await _client.get(_uri('/opml'), headers: _headers); | |
| 95 | + if (res.statusCode < 200 || res.statusCode >= 300) { | |
| 96 | + throw ApiException('Export failed (${res.statusCode}).'); | |
| 97 | + } | |
| 98 | + return res.body; | |
| 99 | + } | |
| 100 | + | |
| 101 | + Future<ImportResult> importOpml(String source) async => ImportResult.fromJson( | |
| 102 | + await _post('/opml', {'opml': source}) as Map<String, dynamic>, | |
| 103 | + ); | |
| 88 | 104 | |
| 89 | 105 | Future<RefreshResult> refresh() async => |
| 90 | 106 | RefreshResult.fromJson(await _post('/refresh') as Map<String, dynamic>); |
| @@ -114,15 +130,16 @@ class GleanClient { | ||
| 114 | 130 | _post('/read', {'id': '$id', if (!read) 'undo': '1'}); |
| 115 | 131 | |
| 116 | 132 | Future<int> markAllRead({String feedUrl = ''}) async { |
| 117 | - final j = await _post('/read-all', {if (feedUrl.isNotEmpty) 'feed': feedUrl}) | |
| 118 | - as Map<String, dynamic>; | |
| 133 | + final j = await _post('/read-all', { | |
| 134 | + if (feedUrl.isNotEmpty) 'feed': feedUrl, | |
| 135 | + }) as Map<String, dynamic>; | |
| 119 | 136 | return (j['unread'] as num?)?.toInt() ?? 0; |
| 120 | 137 | } |
| 121 | 138 | |
| 122 | 139 | /// Scrape the original page for feeds that only ship an excerpt. |
| 123 | 140 | Future<String> fetchFullText(int id) async { |
| 124 | - final j = await _post('/fetch-content', {'id': '$id'}) | |
| 125 | - as Map<String, dynamic>; | |
| 141 | + final j = | |
| 142 | + await _post('/fetch-content', {'id': '$id'}) as Map<String, dynamic>; | |
| 126 | 143 | return (j['content'] as String?) ?? ''; |
| 127 | 144 | } |
| 128 | 145 | |
| @@ -18,7 +18,7 @@ class ApiException implements Exception { | |||
| 18 | /// loopback. That is why this class is a thin wrapper rather than a session. | 18 | /// loopback. That is why this class is a thin wrapper rather than a session. |
| 19 | class GleanClient { | 19 | class GleanClient { |
| 20 | GleanClient({required this.baseUrl, this.token = '', http.Client? client}) | 20 | GleanClient({required this.baseUrl, this.token = '', http.Client? client}) |
| 21 | - : _client = client ?? http.Client(); | 21 | + : _client = client ?? http.Client(); |
| 22 | 22 | ||
| 23 | final String baseUrl; | 23 | final String baseUrl; |
| 24 | 24 | ||
| @@ -61,15 +61,19 @@ class GleanClient { | |||
| 61 | _decode(await _client.get(_uri(path, q), headers: _headers)); | 61 | _decode(await _client.get(_uri(path, q), headers: _headers)); |
| 62 | 62 | ||
| 63 | Future<dynamic> _post(String path, [Map<String, String>? form]) async => | 63 | Future<dynamic> _post(String path, [Map<String, String>? form]) async => |
| 64 | - _decode(await _client.post(_uri(path), | 64 | + _decode( |
| 65 | - headers: _headers, body: form ?? const {})); | 65 | + await _client.post( |
| 66 | + _uri(path), | ||
| 67 | + headers: _headers, | ||
| 68 | + body: form ?? const {}, | ||
| 69 | + ), | ||
| 70 | + ); | ||
| 66 | 71 | ||
| 67 | // --- feeds --- | 72 | // --- feeds --- |
| 68 | 73 | ||
| 69 | - Future<List<Feed>> feeds() async => | 74 | + Future<List<Feed>> feeds() async => ((await _get('/feeds')) as List) |
| 70 | - ((await _get('/feeds')) as List) | 75 | + .map((e) => Feed.fromJson(e as Map<String, dynamic>)) |
| 71 | - .map((e) => Feed.fromJson(e as Map<String, dynamic>)) | 76 | + .toList(); |
| 72 | - .toList(); | ||
| 73 | 77 | ||
| 74 | Future<int> unreadCount() async => | 78 | Future<int> unreadCount() async => |
| 75 | ((await _get('/unread')) as Map<String, dynamic>)['count'] as int? ?? 0; | 79 | ((await _get('/unread')) as Map<String, dynamic>)['count'] as int? ?? 0; |
| @@ -82,9 +86,21 @@ class GleanClient { | |||
| 82 | return (j['added'] as num?)?.toInt() ?? 0; | 86 | return (j['added'] as num?)?.toInt() ?? 0; |
| 83 | } | 87 | } |
| 84 | 88 | ||
| 85 | - Future<void> removeFeed(String feedUrl) async => | 89 | + Future<void> removeFeed(String feedUrl) async => _decode( |
| 86 | - _decode(await _client.delete(_uri('/feeds', {'url': feedUrl}), | 90 | + await _client.delete(_uri('/feeds', {'url': feedUrl}), headers: _headers), |
| 87 | - headers: _headers)); | 91 | + ); |
| 92 | + | ||
| 93 | + Future<String> exportOpml() async { | ||
| 94 | + final res = await _client.get(_uri('/opml'), headers: _headers); | ||
| 95 | + if (res.statusCode < 200 || res.statusCode >= 300) { | ||
| 96 | + throw ApiException('Export failed (${res.statusCode}).'); | ||
| 97 | + } | ||
| 98 | + return res.body; | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + Future<ImportResult> importOpml(String source) async => ImportResult.fromJson( | ||
| 102 | + await _post('/opml', {'opml': source}) as Map<String, dynamic>, | ||
| 103 | + ); | ||
| 88 | 104 | ||
| 89 | Future<RefreshResult> refresh() async => | 105 | Future<RefreshResult> refresh() async => |
| 90 | RefreshResult.fromJson(await _post('/refresh') as Map<String, dynamic>); | 106 | RefreshResult.fromJson(await _post('/refresh') as Map<String, dynamic>); |
| @@ -114,15 +130,16 @@ class GleanClient { | |||
| 114 | _post('/read', {'id': '$id', if (!read) 'undo': '1'}); | 130 | _post('/read', {'id': '$id', if (!read) 'undo': '1'}); |
| 115 | 131 | ||
| 116 | Future<int> markAllRead({String feedUrl = ''}) async { | 132 | Future<int> markAllRead({String feedUrl = ''}) async { |
| 117 | - final j = await _post('/read-all', {if (feedUrl.isNotEmpty) 'feed': feedUrl}) | 133 | + final j = await _post('/read-all', { |
| 118 | - as Map<String, dynamic>; | 134 | + if (feedUrl.isNotEmpty) 'feed': feedUrl, |
| 135 | + }) as Map<String, dynamic>; | ||
| 119 | return (j['unread'] as num?)?.toInt() ?? 0; | 136 | return (j['unread'] as num?)?.toInt() ?? 0; |
| 120 | } | 137 | } |
| 121 | 138 | ||
| 122 | /// Scrape the original page for feeds that only ship an excerpt. | 139 | /// Scrape the original page for feeds that only ship an excerpt. |
| 123 | Future<String> fetchFullText(int id) async { | 140 | Future<String> fetchFullText(int id) async { |
| 124 | - final j = await _post('/fetch-content', {'id': '$id'}) | 141 | + final j = |
| 125 | - as Map<String, dynamic>; | 142 | + await _post('/fetch-content', {'id': '$id'}) as Map<String, dynamic>; |
| 126 | return (j['content'] as String?) ?? ''; | 143 | return (j['content'] as String?) ?? ''; |
| 127 | } | 144 | } |
| 128 | 145 | ||
modified
app/lib/src/app_state.dart +9 -1 | @@ -79,6 +79,14 @@ class AppState extends ChangeNotifier { | ||
| 79 | 79 | await load(); |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | + Future<ImportResult> importOpml(String source) async { | |
| 83 | + final result = await client.importOpml(source); | |
| 84 | + await load(); | |
| 85 | + return result; | |
| 86 | + } | |
| 87 | + | |
| 88 | + Future<String> exportOpml() => client.exportOpml(); | |
| 89 | + | |
| 82 | 90 | Future<RefreshResult> refresh() async { |
| 83 | 91 | final result = await client.refresh(); |
| 84 | 92 | await load(); |
| @@ -105,7 +113,7 @@ class AppState extends ChangeNotifier { | ||
| 105 | 113 | |
| 106 | 114 | class AppScope extends InheritedNotifier<AppState> { |
| 107 | 115 | const AppScope({super.key, required AppState state, required super.child}) |
| 108 | - : super(notifier: state); | |
| 116 | + : super(notifier: state); | |
| 109 | 117 | |
| 110 | 118 | static AppState of(BuildContext context) => |
| 111 | 119 | context.dependOnInheritedWidgetOfExactType<AppScope>()!.notifier!; |
| @@ -79,6 +79,14 @@ class AppState extends ChangeNotifier { | |||
| 79 | await load(); | 79 | await load(); |
| 80 | } | 80 | } |
| 81 | 81 | ||
| 82 | + Future<ImportResult> importOpml(String source) async { | ||
| 83 | + final result = await client.importOpml(source); | ||
| 84 | + await load(); | ||
| 85 | + return result; | ||
| 86 | + } | ||
| 87 | + | ||
| 88 | + Future<String> exportOpml() => client.exportOpml(); | ||
| 89 | + | ||
| 82 | Future<RefreshResult> refresh() async { | 90 | Future<RefreshResult> refresh() async { |
| 83 | final result = await client.refresh(); | 91 | final result = await client.refresh(); |
| 84 | await load(); | 92 | await load(); |
| @@ -105,7 +113,7 @@ class AppState extends ChangeNotifier { | |||
| 105 | 113 | ||
| 106 | class AppScope extends InheritedNotifier<AppState> { | 114 | class AppScope extends InheritedNotifier<AppState> { |
| 107 | const AppScope({super.key, required AppState state, required super.child}) | 115 | const AppScope({super.key, required AppState state, required super.child}) |
| 108 | - : super(notifier: state); | 116 | + : super(notifier: state); |
| 109 | 117 | ||
| 110 | static AppState of(BuildContext context) => | 118 | static AppState of(BuildContext context) => |
| 111 | context.dependOnInheritedWidgetOfExactType<AppScope>()!.notifier!; | 119 | context.dependOnInheritedWidgetOfExactType<AppScope>()!.notifier!; |
modified
app/lib/src/models/models.dart +59 -39 | @@ -38,30 +38,30 @@ class Article { | ||
| 38 | 38 | final bool isRead; |
| 39 | 39 | |
| 40 | 40 | factory Article.fromJson(Map<String, dynamic> j) => Article( |
| 41 | - id: _int(j['id']), | |
| 42 | - feedUrl: _str(j['feed_url']), | |
| 43 | - feedTitle: _str(j['feed_title']), | |
| 44 | - title: _str(j['title']), | |
| 45 | - url: _str(j['url']), | |
| 46 | - author: _str(j['author']), | |
| 47 | - summary: _str(j['summary']), | |
| 48 | - content: _str(j['content']), | |
| 49 | - published: DateTime.tryParse(_str(j['published']))?.toLocal(), | |
| 50 | - isRead: _bool(j['is_read']), | |
| 51 | - ); | |
| 41 | + id: _int(j['id']), | |
| 42 | + feedUrl: _str(j['feed_url']), | |
| 43 | + feedTitle: _str(j['feed_title']), | |
| 44 | + title: _str(j['title']), | |
| 45 | + url: _str(j['url']), | |
| 46 | + author: _str(j['author']), | |
| 47 | + summary: _str(j['summary']), | |
| 48 | + content: _str(j['content']), | |
| 49 | + published: DateTime.tryParse(_str(j['published']))?.toLocal(), | |
| 50 | + isRead: _bool(j['is_read']), | |
| 51 | + ); | |
| 52 | 52 | |
| 53 | 53 | Article copyWith({bool? isRead, String? content}) => Article( |
| 54 | - id: id, | |
| 55 | - feedUrl: feedUrl, | |
| 56 | - feedTitle: feedTitle, | |
| 57 | - title: title, | |
| 58 | - url: url, | |
| 59 | - author: author, | |
| 60 | - summary: summary, | |
| 61 | - content: content ?? this.content, | |
| 62 | - published: published, | |
| 63 | - isRead: isRead ?? this.isRead, | |
| 64 | - ); | |
| 54 | + id: id, | |
| 55 | + feedUrl: feedUrl, | |
| 56 | + feedTitle: feedTitle, | |
| 57 | + title: title, | |
| 58 | + url: url, | |
| 59 | + author: author, | |
| 60 | + summary: summary, | |
| 61 | + content: content ?? this.content, | |
| 62 | + published: published, | |
| 63 | + isRead: isRead ?? this.isRead, | |
| 64 | + ); | |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | 67 | class Feed { |
| @@ -81,22 +81,22 @@ class Feed { | ||
| 81 | 81 | |
| 82 | 82 | /// The "all feeds" row, which the server does not send. |
| 83 | 83 | factory Feed.all(int unread) => Feed( |
| 84 | - feedUrl: '', | |
| 85 | - title: 'All feeds', | |
| 86 | - category: '', | |
| 87 | - unread: unread, | |
| 88 | - faviconUrl: '', | |
| 89 | - ); | |
| 84 | + feedUrl: '', | |
| 85 | + title: 'All feeds', | |
| 86 | + category: '', | |
| 87 | + unread: unread, | |
| 88 | + faviconUrl: '', | |
| 89 | + ); | |
| 90 | 90 | |
| 91 | 91 | bool get isAll => feedUrl.isEmpty; |
| 92 | 92 | |
| 93 | 93 | factory Feed.fromJson(Map<String, dynamic> j) => Feed( |
| 94 | - feedUrl: _str(j['feed_url']), | |
| 95 | - title: _str(j['title']), | |
| 96 | - category: _str(j['category']), | |
| 97 | - unread: _int(j['unread']), | |
| 98 | - faviconUrl: _str(j['favicon_url']), | |
| 99 | - ); | |
| 94 | + feedUrl: _str(j['feed_url']), | |
| 95 | + title: _str(j['title']), | |
| 96 | + category: _str(j['category']), | |
| 97 | + unread: _int(j['unread']), | |
| 98 | + faviconUrl: _str(j['favicon_url']), | |
| 99 | + ); | |
| 100 | 100 | } |
| 101 | 101 | |
| 102 | 102 | /// What a refresh reports back: how many arrived, and which feeds failed. |
| @@ -107,9 +107,29 @@ class RefreshResult { | ||
| 107 | 107 | final List<String> errors; |
| 108 | 108 | |
| 109 | 109 | factory RefreshResult.fromJson(Map<String, dynamic> j) => RefreshResult( |
| 110 | - added: _int(j['added']), | |
| 111 | - errors: ((j['errors'] as List?) ?? const []) | |
| 112 | - .map((e) => _str((e as Map<String, dynamic>)['feed_url'])) | |
| 113 | - .toList(), | |
| 114 | - ); | |
| 110 | + added: _int(j['added']), | |
| 111 | + errors: ((j['errors'] as List?) ?? const []) | |
| 112 | + .map((e) => _str((e as Map<String, dynamic>)['feed_url'])) | |
| 113 | + .toList(), | |
| 114 | + ); | |
| 115 | +} | |
| 116 | + | |
| 117 | +class ImportResult { | |
| 118 | + const ImportResult({ | |
| 119 | + required this.imported, | |
| 120 | + required this.added, | |
| 121 | + required this.errors, | |
| 122 | + }); | |
| 123 | + final int imported; | |
| 124 | + final int added; | |
| 125 | + final List<Map<String, dynamic>> errors; | |
| 126 | + | |
| 127 | + factory ImportResult.fromJson(Map<String, dynamic> json) => ImportResult( | |
| 128 | + imported: (json['imported'] as num?)?.toInt() ?? 0, | |
| 129 | + added: (json['added'] as num?)?.toInt() ?? 0, | |
| 130 | + errors: ((json['errors'] as List?) ?? const []) | |
| 131 | + .whereType<Map>() | |
| 132 | + .map((e) => Map<String, dynamic>.from(e)) | |
| 133 | + .toList(), | |
| 134 | + ); | |
| 115 | 135 | } |
| @@ -38,30 +38,30 @@ class Article { | |||
| 38 | final bool isRead; | 38 | final bool isRead; |
| 39 | 39 | ||
| 40 | factory Article.fromJson(Map<String, dynamic> j) => Article( | 40 | factory Article.fromJson(Map<String, dynamic> j) => Article( |
| 41 | - id: _int(j['id']), | 41 | + id: _int(j['id']), |
| 42 | - feedUrl: _str(j['feed_url']), | 42 | + feedUrl: _str(j['feed_url']), |
| 43 | - feedTitle: _str(j['feed_title']), | 43 | + feedTitle: _str(j['feed_title']), |
| 44 | - title: _str(j['title']), | 44 | + title: _str(j['title']), |
| 45 | - url: _str(j['url']), | 45 | + url: _str(j['url']), |
| 46 | - author: _str(j['author']), | 46 | + author: _str(j['author']), |
| 47 | - summary: _str(j['summary']), | 47 | + summary: _str(j['summary']), |
| 48 | - content: _str(j['content']), | 48 | + content: _str(j['content']), |
| 49 | - published: DateTime.tryParse(_str(j['published']))?.toLocal(), | 49 | + published: DateTime.tryParse(_str(j['published']))?.toLocal(), |
| 50 | - isRead: _bool(j['is_read']), | 50 | + isRead: _bool(j['is_read']), |
| 51 | - ); | 51 | + ); |
| 52 | 52 | ||
| 53 | Article copyWith({bool? isRead, String? content}) => Article( | 53 | Article copyWith({bool? isRead, String? content}) => Article( |
| 54 | - id: id, | 54 | + id: id, |
| 55 | - feedUrl: feedUrl, | 55 | + feedUrl: feedUrl, |
| 56 | - feedTitle: feedTitle, | 56 | + feedTitle: feedTitle, |
| 57 | - title: title, | 57 | + title: title, |
| 58 | - url: url, | 58 | + url: url, |
| 59 | - author: author, | 59 | + author: author, |
| 60 | - summary: summary, | 60 | + summary: summary, |
| 61 | - content: content ?? this.content, | 61 | + content: content ?? this.content, |
| 62 | - published: published, | 62 | + published: published, |
| 63 | - isRead: isRead ?? this.isRead, | 63 | + isRead: isRead ?? this.isRead, |
| 64 | - ); | 64 | + ); |
| 65 | } | 65 | } |
| 66 | 66 | ||
| 67 | class Feed { | 67 | class Feed { |
| @@ -81,22 +81,22 @@ class Feed { | |||
| 81 | 81 | ||
| 82 | /// The "all feeds" row, which the server does not send. | 82 | /// The "all feeds" row, which the server does not send. |
| 83 | factory Feed.all(int unread) => Feed( | 83 | factory Feed.all(int unread) => Feed( |
| 84 | - feedUrl: '', | 84 | + feedUrl: '', |
| 85 | - title: 'All feeds', | 85 | + title: 'All feeds', |
| 86 | - category: '', | 86 | + category: '', |
| 87 | - unread: unread, | 87 | + unread: unread, |
| 88 | - faviconUrl: '', | 88 | + faviconUrl: '', |
| 89 | - ); | 89 | + ); |
| 90 | 90 | ||
| 91 | bool get isAll => feedUrl.isEmpty; | 91 | bool get isAll => feedUrl.isEmpty; |
| 92 | 92 | ||
| 93 | factory Feed.fromJson(Map<String, dynamic> j) => Feed( | 93 | factory Feed.fromJson(Map<String, dynamic> j) => Feed( |
| 94 | - feedUrl: _str(j['feed_url']), | 94 | + feedUrl: _str(j['feed_url']), |
| 95 | - title: _str(j['title']), | 95 | + title: _str(j['title']), |
| 96 | - category: _str(j['category']), | 96 | + category: _str(j['category']), |
| 97 | - unread: _int(j['unread']), | 97 | + unread: _int(j['unread']), |
| 98 | - faviconUrl: _str(j['favicon_url']), | 98 | + faviconUrl: _str(j['favicon_url']), |
| 99 | - ); | 99 | + ); |
| 100 | } | 100 | } |
| 101 | 101 | ||
| 102 | /// What a refresh reports back: how many arrived, and which feeds failed. | 102 | /// What a refresh reports back: how many arrived, and which feeds failed. |
| @@ -107,9 +107,29 @@ class RefreshResult { | |||
| 107 | final List<String> errors; | 107 | final List<String> errors; |
| 108 | 108 | ||
| 109 | factory RefreshResult.fromJson(Map<String, dynamic> j) => RefreshResult( | 109 | factory RefreshResult.fromJson(Map<String, dynamic> j) => RefreshResult( |
| 110 | - added: _int(j['added']), | 110 | + added: _int(j['added']), |
| 111 | - errors: ((j['errors'] as List?) ?? const []) | 111 | + errors: ((j['errors'] as List?) ?? const []) |
| 112 | - .map((e) => _str((e as Map<String, dynamic>)['feed_url'])) | 112 | + .map((e) => _str((e as Map<String, dynamic>)['feed_url'])) |
| 113 | - .toList(), | 113 | + .toList(), |
| 114 | - ); | 114 | + ); |
| 115 | +} | ||
| 116 | + | ||
| 117 | +class ImportResult { | ||
| 118 | + const ImportResult({ | ||
| 119 | + required this.imported, | ||
| 120 | + required this.added, | ||
| 121 | + required this.errors, | ||
| 122 | + }); | ||
| 123 | + final int imported; | ||
| 124 | + final int added; | ||
| 125 | + final List<Map<String, dynamic>> errors; | ||
| 126 | + | ||
| 127 | + factory ImportResult.fromJson(Map<String, dynamic> json) => ImportResult( | ||
| 128 | + imported: (json['imported'] as num?)?.toInt() ?? 0, | ||
| 129 | + added: (json['added'] as num?)?.toInt() ?? 0, | ||
| 130 | + errors: ((json['errors'] as List?) ?? const []) | ||
| 131 | + .whereType<Map>() | ||
| 132 | + .map((e) => Map<String, dynamic>.from(e)) | ||
| 133 | + .toList(), | ||
| 134 | + ); | ||
| 115 | } | 135 | } |
modified
app/lib/src/screens/home_shell.dart +135 -27 | @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import 'package:flutter/material.dart'; |
| 2 | +import 'package:flutter/services.dart'; | |
| 2 | 3 | |
| 3 | 4 | import '../api/client.dart'; |
| 4 | 5 | import '../app_state.dart'; |
| @@ -71,11 +72,13 @@ class _HomeShellState extends State<HomeShell> { | ||
| 71 | 72 | title: Text('Mark $scope read?'), |
| 72 | 73 | actions: [ |
| 73 | 74 | TextButton( |
| 74 | - onPressed: () => Navigator.pop(ctx, false), | |
| 75 | - child: const Text('Cancel')), | |
| 75 | + onPressed: () => Navigator.pop(ctx, false), | |
| 76 | + child: const Text('Cancel'), | |
| 77 | + ), | |
| 76 | 78 | TextButton( |
| 77 | - onPressed: () => Navigator.pop(ctx, true), | |
| 78 | - child: const Text('Mark read')), | |
| 79 | + onPressed: () => Navigator.pop(ctx, true), | |
| 80 | + child: const Text('Mark read'), | |
| 81 | + ), | |
| 79 | 82 | ], |
| 80 | 83 | ), |
| 81 | 84 | ); |
| @@ -92,11 +95,13 @@ class _HomeShellState extends State<HomeShell> { | ||
| 92 | 95 | content: Text(f.title), |
| 93 | 96 | actions: [ |
| 94 | 97 | TextButton( |
| 95 | - onPressed: () => Navigator.pop(ctx, false), | |
| 96 | - child: const Text('Cancel')), | |
| 98 | + onPressed: () => Navigator.pop(ctx, false), | |
| 99 | + child: const Text('Cancel'), | |
| 100 | + ), | |
| 97 | 101 | TextButton( |
| 98 | - onPressed: () => Navigator.pop(ctx, true), | |
| 99 | - child: const Text('Unsubscribe')), | |
| 102 | + onPressed: () => Navigator.pop(ctx, true), | |
| 103 | + child: const Text('Unsubscribe'), | |
| 104 | + ), | |
| 100 | 105 | ], |
| 101 | 106 | ), |
| 102 | 107 | ); |
| @@ -107,6 +112,51 @@ class _HomeShellState extends State<HomeShell> { | ||
| 107 | 112 | } |
| 108 | 113 | } |
| 109 | 114 | |
| 115 | + Future<void> _manageFeeds() async { | |
| 116 | + final action = await showModalBottomSheet<String>( | |
| 117 | + context: context, | |
| 118 | + builder: (ctx) => SafeArea( | |
| 119 | + child: Column( | |
| 120 | + mainAxisSize: MainAxisSize.min, | |
| 121 | + children: [ | |
| 122 | + ListTile( | |
| 123 | + leading: const Icon(Icons.upload_file), | |
| 124 | + title: const Text('Import OPML'), | |
| 125 | + onTap: () => Navigator.pop(ctx, 'import'), | |
| 126 | + ), | |
| 127 | + ListTile( | |
| 128 | + leading: const Icon(Icons.download), | |
| 129 | + title: const Text('Copy OPML backup'), | |
| 130 | + onTap: () => Navigator.pop(ctx, 'export'), | |
| 131 | + ), | |
| 132 | + ], | |
| 133 | + ), | |
| 134 | + ), | |
| 135 | + ); | |
| 136 | + if (!mounted || action == null) return; | |
| 137 | + try { | |
| 138 | + if (action == 'export') { | |
| 139 | + final opml = await AppScope.read(context).exportOpml(); | |
| 140 | + await Clipboard.setData(ClipboardData(text: opml)); | |
| 141 | + if (mounted) showToast(context, 'OPML backup copied to clipboard'); | |
| 142 | + } else { | |
| 143 | + final source = await showDialog<String>( | |
| 144 | + context: context, | |
| 145 | + builder: (_) => const _ImportOpmlDialog(), | |
| 146 | + ); | |
| 147 | + if (source == null || source.trim().isEmpty || !mounted) return; | |
| 148 | + final result = await AppScope.read(context).importOpml(source); | |
| 149 | + if (mounted) | |
| 150 | + showToast( | |
| 151 | + context, | |
| 152 | + '${result.added} feed(s) added${result.errors.isEmpty ? '' : ', ${result.errors.length} failed'}', | |
| 153 | + ); | |
| 154 | + } | |
| 155 | + } on ApiException catch (e) { | |
| 156 | + if (mounted) showToast(context, e.message); | |
| 157 | + } | |
| 158 | + } | |
| 159 | + | |
| 110 | 160 | @override |
| 111 | 161 | Widget build(BuildContext context) { |
| 112 | 162 | final app = AppScope.of(context); |
| @@ -127,7 +177,8 @@ class _HomeShellState extends State<HomeShell> { | ||
| 127 | 177 | ? const SizedBox( |
| 128 | 178 | width: 18, |
| 129 | 179 | height: 18, |
| 130 | - child: CircularProgressIndicator(strokeWidth: 2)) | |
| 180 | + child: CircularProgressIndicator(strokeWidth: 2), | |
| 181 | + ) | |
| 131 | 182 | : const Icon(Icons.refresh), |
| 132 | 183 | onPressed: _refreshing ? null : _refresh, |
| 133 | 184 | ), |
| @@ -139,25 +190,37 @@ class _HomeShellState extends State<HomeShell> { | ||
| 139 | 190 | child: Column( |
| 140 | 191 | children: [ |
| 141 | 192 | ListTile( |
| 142 | - title: Text('Feeds', | |
| 143 | - style: Theme.of(context).textTheme.titleMedium), | |
| 144 | - trailing: IconButton( | |
| 145 | - icon: const Icon(Icons.add), | |
| 146 | - tooltip: 'Add feed', | |
| 147 | - onPressed: () { | |
| 148 | - Navigator.of(context).pop(); | |
| 149 | - _addFeed(); | |
| 150 | - }, | |
| 193 | + title: Text( | |
| 194 | + 'Feeds', | |
| 195 | + style: Theme.of(context).textTheme.titleMedium, | |
| 196 | + ), | |
| 197 | + trailing: Row( | |
| 198 | + mainAxisSize: MainAxisSize.min, | |
| 199 | + children: [ | |
| 200 | + IconButton( | |
| 201 | + icon: const Icon(Icons.more_horiz), | |
| 202 | + tooltip: 'Manage feeds', | |
| 203 | + onPressed: _manageFeeds, | |
| 204 | + ), | |
| 205 | + IconButton( | |
| 206 | + icon: const Icon(Icons.add), | |
| 207 | + tooltip: 'Add feed', | |
| 208 | + onPressed: () { | |
| 209 | + Navigator.of(context).pop(); | |
| 210 | + _addFeed(); | |
| 211 | + }, | |
| 212 | + ), | |
| 213 | + ], | |
| 151 | 214 | ), |
| 152 | 215 | ), |
| 153 | 216 | if (app.error != null) |
| 154 | 217 | Padding( |
| 155 | 218 | padding: const EdgeInsets.all(16), |
| 156 | - child: Text(app.error!, | |
| 157 | - style: Theme.of(context) | |
| 158 | - .textTheme | |
| 159 | - .bodySmall | |
| 160 | - ?.copyWith(color: c.danger)), | |
| 219 | + child: Text( | |
| 220 | + app.error!, | |
| 221 | + style: Theme.of(context).textTheme.bodySmall | |
| 222 | + ?.copyWith(color: c.danger), | |
| 223 | + ), | |
| 161 | 224 | ), |
| 162 | 225 | Expanded( |
| 163 | 226 | child: ListView( |
| @@ -165,8 +228,11 @@ class _HomeShellState extends State<HomeShell> { | ||
| 165 | 228 | for (final f in app.sidebar) |
| 166 | 229 | ListTile( |
| 167 | 230 | selected: f.feedUrl == _selected.feedUrl, |
| 168 | - title: Text(f.title, | |
| 169 | - maxLines: 1, overflow: TextOverflow.ellipsis), | |
| 231 | + title: Text( | |
| 232 | + f.title, | |
| 233 | + maxLines: 1, | |
| 234 | + overflow: TextOverflow.ellipsis, | |
| 235 | + ), | |
| 170 | 236 | trailing: f.unread > 0 |
| 171 | 237 | ? GleanTag('${f.unread}', emphasis: !f.isAll) |
| 172 | 238 | : null, |
| @@ -195,6 +261,44 @@ class _AddFeedDialog extends StatefulWidget { | ||
| 195 | 261 | State<_AddFeedDialog> createState() => _AddFeedDialogState(); |
| 196 | 262 | } |
| 197 | 263 | |
| 264 | +class _ImportOpmlDialog extends StatefulWidget { | |
| 265 | + const _ImportOpmlDialog(); | |
| 266 | + @override | |
| 267 | + State<_ImportOpmlDialog> createState() => _ImportOpmlDialogState(); | |
| 268 | +} | |
| 269 | + | |
| 270 | +class _ImportOpmlDialogState extends State<_ImportOpmlDialog> { | |
| 271 | + final _controller = TextEditingController(); | |
| 272 | + @override | |
| 273 | + void dispose() { | |
| 274 | + _controller.dispose(); | |
| 275 | + super.dispose(); | |
| 276 | + } | |
| 277 | + | |
| 278 | + @override | |
| 279 | + Widget build(BuildContext context) => AlertDialog( | |
| 280 | + shape: const RoundedRectangleBorder(), | |
| 281 | + title: const Text('Import OPML'), | |
| 282 | + content: TextField( | |
| 283 | + controller: _controller, | |
| 284 | + autofocus: true, | |
| 285 | + minLines: 8, | |
| 286 | + maxLines: 14, | |
| 287 | + decoration: const InputDecoration(hintText: 'Paste your OPML here'), | |
| 288 | + ), | |
| 289 | + actions: [ | |
| 290 | + TextButton( | |
| 291 | + onPressed: () => Navigator.pop(context), | |
| 292 | + child: const Text('Cancel'), | |
| 293 | + ), | |
| 294 | + TextButton( | |
| 295 | + onPressed: () => Navigator.pop(context, _controller.text), | |
| 296 | + child: const Text('Import'), | |
| 297 | + ), | |
| 298 | + ], | |
| 299 | + ); | |
| 300 | +} | |
| 301 | + | |
| 198 | 302 | class _AddFeedDialogState extends State<_AddFeedDialog> { |
| 199 | 303 | final _controller = TextEditingController(); |
| 200 | 304 | |
| @@ -213,12 +317,16 @@ class _AddFeedDialogState extends State<_AddFeedDialog> { | ||
| 213 | 317 | controller: _controller, |
| 214 | 318 | autofocus: true, |
| 215 | 319 | keyboardType: TextInputType.url, |
| 216 | - decoration: const InputDecoration(hintText: 'https://example.com/feed.xml'), | |
| 320 | + decoration: const InputDecoration( | |
| 321 | + hintText: 'https://example.com/feed.xml', | |
| 322 | + ), | |
| 217 | 323 | onSubmitted: (v) => Navigator.pop(context, v.trim()), |
| 218 | 324 | ), |
| 219 | 325 | actions: [ |
| 220 | 326 | TextButton( |
| 221 | - onPressed: () => Navigator.pop(context), child: const Text('Cancel')), | |
| 327 | + onPressed: () => Navigator.pop(context), | |
| 328 | + child: const Text('Cancel'), | |
| 329 | + ), | |
| 222 | 330 | TextButton( |
| 223 | 331 | onPressed: () => Navigator.pop(context, _controller.text.trim()), |
| 224 | 332 | child: const Text('Add'), |
| @@ -1,4 +1,5 @@ | |||
| 1 | import 'package:flutter/material.dart'; | 1 | import 'package:flutter/material.dart'; |
| 2 | +import 'package:flutter/services.dart'; | ||
| 2 | 3 | ||
| 3 | import '../api/client.dart'; | 4 | import '../api/client.dart'; |
| 4 | import '../app_state.dart'; | 5 | import '../app_state.dart'; |
| @@ -71,11 +72,13 @@ class _HomeShellState extends State<HomeShell> { | |||
| 71 | title: Text('Mark $scope read?'), | 72 | title: Text('Mark $scope read?'), |
| 72 | actions: [ | 73 | actions: [ |
| 73 | TextButton( | 74 | TextButton( |
| 74 | - onPressed: () => Navigator.pop(ctx, false), | 75 | + onPressed: () => Navigator.pop(ctx, false), |
| 75 | - child: const Text('Cancel')), | 76 | + child: const Text('Cancel'), |
| 77 | + ), | ||
| 76 | TextButton( | 78 | TextButton( |
| 77 | - onPressed: () => Navigator.pop(ctx, true), | 79 | + onPressed: () => Navigator.pop(ctx, true), |
| 78 | - child: const Text('Mark read')), | 80 | + child: const Text('Mark read'), |
| 81 | + ), | ||
| 79 | ], | 82 | ], |
| 80 | ), | 83 | ), |
| 81 | ); | 84 | ); |
| @@ -92,11 +95,13 @@ class _HomeShellState extends State<HomeShell> { | |||
| 92 | content: Text(f.title), | 95 | content: Text(f.title), |
| 93 | actions: [ | 96 | actions: [ |
| 94 | TextButton( | 97 | TextButton( |
| 95 | - onPressed: () => Navigator.pop(ctx, false), | 98 | + onPressed: () => Navigator.pop(ctx, false), |
| 96 | - child: const Text('Cancel')), | 99 | + child: const Text('Cancel'), |
| 100 | + ), | ||
| 97 | TextButton( | 101 | TextButton( |
| 98 | - onPressed: () => Navigator.pop(ctx, true), | 102 | + onPressed: () => Navigator.pop(ctx, true), |
| 99 | - child: const Text('Unsubscribe')), | 103 | + child: const Text('Unsubscribe'), |
| 104 | + ), | ||
| 100 | ], | 105 | ], |
| 101 | ), | 106 | ), |
| 102 | ); | 107 | ); |
| @@ -107,6 +112,51 @@ class _HomeShellState extends State<HomeShell> { | |||
| 107 | } | 112 | } |
| 108 | } | 113 | } |
| 109 | 114 | ||
| 115 | + Future<void> _manageFeeds() async { | ||
| 116 | + final action = await showModalBottomSheet<String>( | ||
| 117 | + context: context, | ||
| 118 | + builder: (ctx) => SafeArea( | ||
| 119 | + child: Column( | ||
| 120 | + mainAxisSize: MainAxisSize.min, | ||
| 121 | + children: [ | ||
| 122 | + ListTile( | ||
| 123 | + leading: const Icon(Icons.upload_file), | ||
| 124 | + title: const Text('Import OPML'), | ||
| 125 | + onTap: () => Navigator.pop(ctx, 'import'), | ||
| 126 | + ), | ||
| 127 | + ListTile( | ||
| 128 | + leading: const Icon(Icons.download), | ||
| 129 | + title: const Text('Copy OPML backup'), | ||
| 130 | + onTap: () => Navigator.pop(ctx, 'export'), | ||
| 131 | + ), | ||
| 132 | + ], | ||
| 133 | + ), | ||
| 134 | + ), | ||
| 135 | + ); | ||
| 136 | + if (!mounted || action == null) return; | ||
| 137 | + try { | ||
| 138 | + if (action == 'export') { | ||
| 139 | + final opml = await AppScope.read(context).exportOpml(); | ||
| 140 | + await Clipboard.setData(ClipboardData(text: opml)); | ||
| 141 | + if (mounted) showToast(context, 'OPML backup copied to clipboard'); | ||
| 142 | + } else { | ||
| 143 | + final source = await showDialog<String>( | ||
| 144 | + context: context, | ||
| 145 | + builder: (_) => const _ImportOpmlDialog(), | ||
| 146 | + ); | ||
| 147 | + if (source == null || source.trim().isEmpty || !mounted) return; | ||
| 148 | + final result = await AppScope.read(context).importOpml(source); | ||
| 149 | + if (mounted) | ||
| 150 | + showToast( | ||
| 151 | + context, | ||
| 152 | + '${result.added} feed(s) added${result.errors.isEmpty ? '' : ', ${result.errors.length} failed'}', | ||
| 153 | + ); | ||
| 154 | + } | ||
| 155 | + } on ApiException catch (e) { | ||
| 156 | + if (mounted) showToast(context, e.message); | ||
| 157 | + } | ||
| 158 | + } | ||
| 159 | + | ||
| 110 | @override | 160 | @override |
| 111 | Widget build(BuildContext context) { | 161 | Widget build(BuildContext context) { |
| 112 | final app = AppScope.of(context); | 162 | final app = AppScope.of(context); |
| @@ -127,7 +177,8 @@ class _HomeShellState extends State<HomeShell> { | |||
| 127 | ? const SizedBox( | 177 | ? const SizedBox( |
| 128 | width: 18, | 178 | width: 18, |
| 129 | height: 18, | 179 | height: 18, |
| 130 | - child: CircularProgressIndicator(strokeWidth: 2)) | 180 | + child: CircularProgressIndicator(strokeWidth: 2), |
| 181 | + ) | ||
| 131 | : const Icon(Icons.refresh), | 182 | : const Icon(Icons.refresh), |
| 132 | onPressed: _refreshing ? null : _refresh, | 183 | onPressed: _refreshing ? null : _refresh, |
| 133 | ), | 184 | ), |
| @@ -139,25 +190,37 @@ class _HomeShellState extends State<HomeShell> { | |||
| 139 | child: Column( | 190 | child: Column( |
| 140 | children: [ | 191 | children: [ |
| 141 | ListTile( | 192 | ListTile( |
| 142 | - title: Text('Feeds', | 193 | + title: Text( |
| 143 | - style: Theme.of(context).textTheme.titleMedium), | 194 | + 'Feeds', |
| 144 | - trailing: IconButton( | 195 | + style: Theme.of(context).textTheme.titleMedium, |
| 145 | - icon: const Icon(Icons.add), | 196 | + ), |
| 146 | - tooltip: 'Add feed', | 197 | + trailing: Row( |
| 147 | - onPressed: () { | 198 | + mainAxisSize: MainAxisSize.min, |
| 148 | - Navigator.of(context).pop(); | 199 | + children: [ |
| 149 | - _addFeed(); | 200 | + IconButton( |
| 150 | - }, | 201 | + icon: const Icon(Icons.more_horiz), |
| 202 | + tooltip: 'Manage feeds', | ||
| 203 | + onPressed: _manageFeeds, | ||
| 204 | + ), | ||
| 205 | + IconButton( | ||
| 206 | + icon: const Icon(Icons.add), | ||
| 207 | + tooltip: 'Add feed', | ||
| 208 | + onPressed: () { | ||
| 209 | + Navigator.of(context).pop(); | ||
| 210 | + _addFeed(); | ||
| 211 | + }, | ||
| 212 | + ), | ||
| 213 | + ], | ||
| 151 | ), | 214 | ), |
| 152 | ), | 215 | ), |
| 153 | if (app.error != null) | 216 | if (app.error != null) |
| 154 | Padding( | 217 | Padding( |
| 155 | padding: const EdgeInsets.all(16), | 218 | padding: const EdgeInsets.all(16), |
| 156 | - child: Text(app.error!, | 219 | + child: Text( |
| 157 | - style: Theme.of(context) | 220 | + app.error!, |
| 158 | - .textTheme | 221 | + style: Theme.of(context).textTheme.bodySmall |
| 159 | - .bodySmall | 222 | + ?.copyWith(color: c.danger), |
| 160 | - ?.copyWith(color: c.danger)), | 223 | + ), |
| 161 | ), | 224 | ), |
| 162 | Expanded( | 225 | Expanded( |
| 163 | child: ListView( | 226 | child: ListView( |
| @@ -165,8 +228,11 @@ class _HomeShellState extends State<HomeShell> { | |||
| 165 | for (final f in app.sidebar) | 228 | for (final f in app.sidebar) |
| 166 | ListTile( | 229 | ListTile( |
| 167 | selected: f.feedUrl == _selected.feedUrl, | 230 | selected: f.feedUrl == _selected.feedUrl, |
| 168 | - title: Text(f.title, | 231 | + title: Text( |
| 169 | - maxLines: 1, overflow: TextOverflow.ellipsis), | 232 | + f.title, |
| 233 | + maxLines: 1, | ||
| 234 | + overflow: TextOverflow.ellipsis, | ||
| 235 | + ), | ||
| 170 | trailing: f.unread > 0 | 236 | trailing: f.unread > 0 |
| 171 | ? GleanTag('${f.unread}', emphasis: !f.isAll) | 237 | ? GleanTag('${f.unread}', emphasis: !f.isAll) |
| 172 | : null, | 238 | : null, |
| @@ -195,6 +261,44 @@ class _AddFeedDialog extends StatefulWidget { | |||
| 195 | State<_AddFeedDialog> createState() => _AddFeedDialogState(); | 261 | State<_AddFeedDialog> createState() => _AddFeedDialogState(); |
| 196 | } | 262 | } |
| 197 | 263 | ||
| 264 | +class _ImportOpmlDialog extends StatefulWidget { | ||
| 265 | + const _ImportOpmlDialog(); | ||
| 266 | + @override | ||
| 267 | + State<_ImportOpmlDialog> createState() => _ImportOpmlDialogState(); | ||
| 268 | +} | ||
| 269 | + | ||
| 270 | +class _ImportOpmlDialogState extends State<_ImportOpmlDialog> { | ||
| 271 | + final _controller = TextEditingController(); | ||
| 272 | + @override | ||
| 273 | + void dispose() { | ||
| 274 | + _controller.dispose(); | ||
| 275 | + super.dispose(); | ||
| 276 | + } | ||
| 277 | + | ||
| 278 | + @override | ||
| 279 | + Widget build(BuildContext context) => AlertDialog( | ||
| 280 | + shape: const RoundedRectangleBorder(), | ||
| 281 | + title: const Text('Import OPML'), | ||
| 282 | + content: TextField( | ||
| 283 | + controller: _controller, | ||
| 284 | + autofocus: true, | ||
| 285 | + minLines: 8, | ||
| 286 | + maxLines: 14, | ||
| 287 | + decoration: const InputDecoration(hintText: 'Paste your OPML here'), | ||
| 288 | + ), | ||
| 289 | + actions: [ | ||
| 290 | + TextButton( | ||
| 291 | + onPressed: () => Navigator.pop(context), | ||
| 292 | + child: const Text('Cancel'), | ||
| 293 | + ), | ||
| 294 | + TextButton( | ||
| 295 | + onPressed: () => Navigator.pop(context, _controller.text), | ||
| 296 | + child: const Text('Import'), | ||
| 297 | + ), | ||
| 298 | + ], | ||
| 299 | + ); | ||
| 300 | +} | ||
| 301 | + | ||
| 198 | class _AddFeedDialogState extends State<_AddFeedDialog> { | 302 | class _AddFeedDialogState extends State<_AddFeedDialog> { |
| 199 | final _controller = TextEditingController(); | 303 | final _controller = TextEditingController(); |
| 200 | 304 | ||
| @@ -213,12 +317,16 @@ class _AddFeedDialogState extends State<_AddFeedDialog> { | |||
| 213 | controller: _controller, | 317 | controller: _controller, |
| 214 | autofocus: true, | 318 | autofocus: true, |
| 215 | keyboardType: TextInputType.url, | 319 | keyboardType: TextInputType.url, |
| 216 | - decoration: const InputDecoration(hintText: 'https://example.com/feed.xml'), | 320 | + decoration: const InputDecoration( |
| 321 | + hintText: 'https://example.com/feed.xml', | ||
| 322 | + ), | ||
| 217 | onSubmitted: (v) => Navigator.pop(context, v.trim()), | 323 | onSubmitted: (v) => Navigator.pop(context, v.trim()), |
| 218 | ), | 324 | ), |
| 219 | actions: [ | 325 | actions: [ |
| 220 | TextButton( | 326 | TextButton( |
| 221 | - onPressed: () => Navigator.pop(context), child: const Text('Cancel')), | 327 | + onPressed: () => Navigator.pop(context), |
| 328 | + child: const Text('Cancel'), | ||
| 329 | + ), | ||
| 222 | TextButton( | 330 | TextButton( |
| 223 | onPressed: () => Navigator.pop(context, _controller.text.trim()), | 331 | onPressed: () => Navigator.pop(context, _controller.text.trim()), |
| 224 | child: const Text('Add'), | 332 | child: const Text('Add'), |
modified
spike/nim/glean.nim +37 -1 | @@ -22,7 +22,7 @@ | ||
| 22 | 22 | import std/[asyncdispatch, asynchttpserver, httpclient, json, options, os, |
| 23 | 23 | strformat, strutils, times, uri] |
| 24 | 24 | import reader/[articlestore, feedfetcher, feedparser, feedstore, gleandb, |
| 25 | - scraper, sqlite] | |
| 25 | + opml, scraper, sqlite] | |
| 26 | 26 | |
| 27 | 27 | const |
| 28 | 28 | ## Baked in at compile time rather than read from disk, so the reader stays |
| @@ -229,6 +229,15 @@ proc handle(r: Reader, req: Request) {.async.} = | ||
| 229 | 229 | items.add toJson(s, s.unreadCount) |
| 230 | 230 | await jsonResponse(req, Http200, items) |
| 231 | 231 | |
| 232 | + of "opml": | |
| 233 | + var feeds: seq[OpmlFeed] | |
| 234 | + for s in r.db.listSubscriptions(LocalUser, "", 5000, 0): | |
| 235 | + feeds.add OpmlFeed(url: s.feedUrl, title: s.feedTitle, category: s.category) | |
| 236 | + await req.respond(Http200, renderOpml(feeds), newHttpHeaders({ | |
| 237 | + "Content-Type": "application/xml; charset=utf-8", | |
| 238 | + "Content-Disposition": "attachment; filename=glean-subscriptions.opml", | |
| 239 | + })) | |
| 240 | + | |
| 232 | 241 | of "articles": |
| 233 | 242 | if segs.len == 2: |
| 234 | 243 | # One article, with its full text if we have it. |
| @@ -266,6 +275,33 @@ proc handle(r: Reader, req: Request) {.async.} = | ||
| 266 | 275 | |
| 267 | 276 | of HttpPost: |
| 268 | 277 | case segs[0] |
| 278 | + of "opml": | |
| 279 | + let source = param("opml") | |
| 280 | + if source.len == 0: | |
| 281 | + await fail(req, Http400, "OPML required") | |
| 282 | + return | |
| 283 | + if source.len > 1_000_000: | |
| 284 | + await fail(req, Http413, "OPML is too large") | |
| 285 | + return | |
| 286 | + var imported: seq[OpmlFeed] | |
| 287 | + try: | |
| 288 | + imported = parseOpml(source) | |
| 289 | + except CatchableError as e: | |
| 290 | + await fail(req, Http400, "invalid OPML: " & e.msg) | |
| 291 | + return | |
| 292 | + var added = 0 | |
| 293 | + var errors = newJArray() | |
| 294 | + for feed in imported: | |
| 295 | + let exists = r.db.getSubscription(LocalUser, feed.url).isSome | |
| 296 | + r.db.upsertFeed(Feed(feedUrl: feed.url, title: feed.title, | |
| 297 | + siteUrl: feed.siteUrl, description: feed.description)) | |
| 298 | + r.db.createSubscription(LocalUser, feed.url, feed.title, feed.category, "", "") | |
| 299 | + if not exists: inc added | |
| 300 | + let (_, err) = r.refreshFeed(feed.url) | |
| 301 | + if err.len > 0: errors.add %*{"feed_url": feed.url, "error": err} | |
| 302 | + await jsonResponse(req, Http200, | |
| 303 | + %*{"imported": imported.len, "added": added, "errors": errors}) | |
| 304 | + | |
| 269 | 305 | of "feeds": |
| 270 | 306 | let url = param("url") |
| 271 | 307 | if url.len == 0: |
| @@ -22,7 +22,7 @@ | |||
| 22 | import std/[asyncdispatch, asynchttpserver, httpclient, json, options, os, | 22 | import std/[asyncdispatch, asynchttpserver, httpclient, json, options, os, |
| 23 | strformat, strutils, times, uri] | 23 | strformat, strutils, times, uri] |
| 24 | import reader/[articlestore, feedfetcher, feedparser, feedstore, gleandb, | 24 | import reader/[articlestore, feedfetcher, feedparser, feedstore, gleandb, |
| 25 | - scraper, sqlite] | 25 | + opml, scraper, sqlite] |
| 26 | 26 | ||
| 27 | const | 27 | const |
| 28 | ## Baked in at compile time rather than read from disk, so the reader stays | 28 | ## Baked in at compile time rather than read from disk, so the reader stays |
| @@ -229,6 +229,15 @@ proc handle(r: Reader, req: Request) {.async.} = | |||
| 229 | items.add toJson(s, s.unreadCount) | 229 | items.add toJson(s, s.unreadCount) |
| 230 | await jsonResponse(req, Http200, items) | 230 | await jsonResponse(req, Http200, items) |
| 231 | 231 | ||
| 232 | + of "opml": | ||
| 233 | + var feeds: seq[OpmlFeed] | ||
| 234 | + for s in r.db.listSubscriptions(LocalUser, "", 5000, 0): | ||
| 235 | + feeds.add OpmlFeed(url: s.feedUrl, title: s.feedTitle, category: s.category) | ||
| 236 | + await req.respond(Http200, renderOpml(feeds), newHttpHeaders({ | ||
| 237 | + "Content-Type": "application/xml; charset=utf-8", | ||
| 238 | + "Content-Disposition": "attachment; filename=glean-subscriptions.opml", | ||
| 239 | + })) | ||
| 240 | + | ||
| 232 | of "articles": | 241 | of "articles": |
| 233 | if segs.len == 2: | 242 | if segs.len == 2: |
| 234 | # One article, with its full text if we have it. | 243 | # One article, with its full text if we have it. |
| @@ -266,6 +275,33 @@ proc handle(r: Reader, req: Request) {.async.} = | |||
| 266 | 275 | ||
| 267 | of HttpPost: | 276 | of HttpPost: |
| 268 | case segs[0] | 277 | case segs[0] |
| 278 | + of "opml": | ||
| 279 | + let source = param("opml") | ||
| 280 | + if source.len == 0: | ||
| 281 | + await fail(req, Http400, "OPML required") | ||
| 282 | + return | ||
| 283 | + if source.len > 1_000_000: | ||
| 284 | + await fail(req, Http413, "OPML is too large") | ||
| 285 | + return | ||
| 286 | + var imported: seq[OpmlFeed] | ||
| 287 | + try: | ||
| 288 | + imported = parseOpml(source) | ||
| 289 | + except CatchableError as e: | ||
| 290 | + await fail(req, Http400, "invalid OPML: " & e.msg) | ||
| 291 | + return | ||
| 292 | + var added = 0 | ||
| 293 | + var errors = newJArray() | ||
| 294 | + for feed in imported: | ||
| 295 | + let exists = r.db.getSubscription(LocalUser, feed.url).isSome | ||
| 296 | + r.db.upsertFeed(Feed(feedUrl: feed.url, title: feed.title, | ||
| 297 | + siteUrl: feed.siteUrl, description: feed.description)) | ||
| 298 | + r.db.createSubscription(LocalUser, feed.url, feed.title, feed.category, "", "") | ||
| 299 | + if not exists: inc added | ||
| 300 | + let (_, err) = r.refreshFeed(feed.url) | ||
| 301 | + if err.len > 0: errors.add %*{"feed_url": feed.url, "error": err} | ||
| 302 | + await jsonResponse(req, Http200, | ||
| 303 | + %*{"imported": imported.len, "added": added, "errors": errors}) | ||
| 304 | + | ||
| 269 | of "feeds": | 305 | of "feeds": |
| 270 | let url = param("url") | 306 | let url = param("url") |
| 271 | if url.len == 0: | 307 | if url.len == 0: |
added
spike/nim/reader/opml.nim +65 -0 | new file mode 100644 | ||
| @@ -0,0 +1,65 @@ | ||
| 1 | +## OPML is the portable backup format for feed subscriptions. Keep this | |
| 2 | +## deliberately small: outlines can be nested arbitrarily, but only entries | |
| 3 | +## with xmlUrl are subscriptions. | |
| 4 | + | |
| 5 | +import std/[strutils, xmlparser, xmltree] | |
| 6 | + | |
| 7 | +type OpmlFeed* = object | |
| 8 | + url*: string | |
| 9 | + title*: string | |
| 10 | + siteUrl*: string | |
| 11 | + description*: string | |
| 12 | + category*: string | |
| 13 | + | |
| 14 | +proc escapeXml(s: string): string = | |
| 15 | + s.replace("&", "&").replace("\"", """).replace("<", "<") | |
| 16 | + | |
| 17 | +proc label(n: XmlNode): string = | |
| 18 | + for key in ["title", "text", "htmlUrl", "xmlUrl"]: | |
| 19 | + let value = n.attr(key).strip() | |
| 20 | + if value.len > 0: return value | |
| 21 | + "" | |
| 22 | + | |
| 23 | +proc collect(n: XmlNode, category: string, result: var seq[OpmlFeed]) = | |
| 24 | + if n == nil or n.kind != xnElement: return | |
| 25 | + if n.tag.toLowerAscii == "outline": | |
| 26 | + let url = n.attr("xmlUrl").strip() | |
| 27 | + if url.len > 0: | |
| 28 | + result.add OpmlFeed(url: url, title: label(n), siteUrl: n.attr("htmlUrl").strip(), | |
| 29 | + description: n.attr("description").strip(), category: category) | |
| 30 | + else: | |
| 31 | + let nextCategory = label(n) | |
| 32 | + for child in n: collect(child, nextCategory, result) | |
| 33 | + else: | |
| 34 | + for child in n: collect(child, category, result) | |
| 35 | + | |
| 36 | +proc parseOpml*(source: string): seq[OpmlFeed] = | |
| 37 | + ## Raises XmlError for malformed XML; callers return it as a 400. | |
| 38 | + let root = parseXml(source) | |
| 39 | + if root == nil or root.tag.toLowerAscii != "opml": | |
| 40 | + raise newException(ValueError, "expected an OPML document") | |
| 41 | + collect(root, "", result) | |
| 42 | + | |
| 43 | +proc renderOpml*(feeds: openArray[OpmlFeed], title = "Glean subscriptions"): string = | |
| 44 | + result = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<opml version=\"2.0\">\n <head><title>" & | |
| 45 | + escapeXml(title) & "</title></head>\n <body>\n" | |
| 46 | + var uncategorized: seq[OpmlFeed] | |
| 47 | + var categories: seq[string] | |
| 48 | + for feed in feeds: | |
| 49 | + if feed.category.len == 0: | |
| 50 | + uncategorized.add feed | |
| 51 | + elif feed.category notin categories: | |
| 52 | + categories.add feed.category | |
| 53 | + proc line(feed: OpmlFeed, indent: string): string = | |
| 54 | + result = indent & "<outline text=\"" & escapeXml(feed.title) & "\" title=\"" & | |
| 55 | + escapeXml(feed.title) & "\" type=\"rss\" xmlUrl=\"" & escapeXml(feed.url) & "\"" | |
| 56 | + if feed.siteUrl.len > 0: result.add " htmlUrl=\"" & escapeXml(feed.siteUrl) & "\"" | |
| 57 | + if feed.description.len > 0: result.add " description=\"" & escapeXml(feed.description) & "\"" | |
| 58 | + result.add "/>\n" | |
| 59 | + for feed in uncategorized: result.add line(feed, " ") | |
| 60 | + for category in categories: | |
| 61 | + result.add " <outline text=\"" & escapeXml(category) & "\" title=\"" & escapeXml(category) & "\">\n" | |
| 62 | + for feed in feeds: | |
| 63 | + if feed.category == category: result.add line(feed, " ") | |
| 64 | + result.add " </outline>\n" | |
| 65 | + result.add " </body>\n</opml>\n" | |
| new file mode 100644 | |||
| @@ -0,0 +1,65 @@ | |||
| 1 | +## OPML is the portable backup format for feed subscriptions. Keep this | ||
| 2 | +## deliberately small: outlines can be nested arbitrarily, but only entries | ||
| 3 | +## with xmlUrl are subscriptions. | ||
| 4 | + | ||
| 5 | +import std/[strutils, xmlparser, xmltree] | ||
| 6 | + | ||
| 7 | +type OpmlFeed* = object | ||
| 8 | + url*: string | ||
| 9 | + title*: string | ||
| 10 | + siteUrl*: string | ||
| 11 | + description*: string | ||
| 12 | + category*: string | ||
| 13 | + | ||
| 14 | +proc escapeXml(s: string): string = | ||
| 15 | + s.replace("&", "&").replace("\"", """).replace("<", "<") | ||
| 16 | + | ||
| 17 | +proc label(n: XmlNode): string = | ||
| 18 | + for key in ["title", "text", "htmlUrl", "xmlUrl"]: | ||
| 19 | + let value = n.attr(key).strip() | ||
| 20 | + if value.len > 0: return value | ||
| 21 | + "" | ||
| 22 | + | ||
| 23 | +proc collect(n: XmlNode, category: string, result: var seq[OpmlFeed]) = | ||
| 24 | + if n == nil or n.kind != xnElement: return | ||
| 25 | + if n.tag.toLowerAscii == "outline": | ||
| 26 | + let url = n.attr("xmlUrl").strip() | ||
| 27 | + if url.len > 0: | ||
| 28 | + result.add OpmlFeed(url: url, title: label(n), siteUrl: n.attr("htmlUrl").strip(), | ||
| 29 | + description: n.attr("description").strip(), category: category) | ||
| 30 | + else: | ||
| 31 | + let nextCategory = label(n) | ||
| 32 | + for child in n: collect(child, nextCategory, result) | ||
| 33 | + else: | ||
| 34 | + for child in n: collect(child, category, result) | ||
| 35 | + | ||
| 36 | +proc parseOpml*(source: string): seq[OpmlFeed] = | ||
| 37 | + ## Raises XmlError for malformed XML; callers return it as a 400. | ||
| 38 | + let root = parseXml(source) | ||
| 39 | + if root == nil or root.tag.toLowerAscii != "opml": | ||
| 40 | + raise newException(ValueError, "expected an OPML document") | ||
| 41 | + collect(root, "", result) | ||
| 42 | + | ||
| 43 | +proc renderOpml*(feeds: openArray[OpmlFeed], title = "Glean subscriptions"): string = | ||
| 44 | + result = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<opml version=\"2.0\">\n <head><title>" & | ||
| 45 | + escapeXml(title) & "</title></head>\n <body>\n" | ||
| 46 | + var uncategorized: seq[OpmlFeed] | ||
| 47 | + var categories: seq[string] | ||
| 48 | + for feed in feeds: | ||
| 49 | + if feed.category.len == 0: | ||
| 50 | + uncategorized.add feed | ||
| 51 | + elif feed.category notin categories: | ||
| 52 | + categories.add feed.category | ||
| 53 | + proc line(feed: OpmlFeed, indent: string): string = | ||
| 54 | + result = indent & "<outline text=\"" & escapeXml(feed.title) & "\" title=\"" & | ||
| 55 | + escapeXml(feed.title) & "\" type=\"rss\" xmlUrl=\"" & escapeXml(feed.url) & "\"" | ||
| 56 | + if feed.siteUrl.len > 0: result.add " htmlUrl=\"" & escapeXml(feed.siteUrl) & "\"" | ||
| 57 | + if feed.description.len > 0: result.add " description=\"" & escapeXml(feed.description) & "\"" | ||
| 58 | + result.add "/>\n" | ||
| 59 | + for feed in uncategorized: result.add line(feed, " ") | ||
| 60 | + for category in categories: | ||
| 61 | + result.add " <outline text=\"" & escapeXml(category) & "\" title=\"" & escapeXml(category) & "\">\n" | ||
| 62 | + for feed in feeds: | ||
| 63 | + if feed.category == category: result.add line(feed, " ") | ||
| 64 | + result.add " </outline>\n" | ||
| 65 | + result.add " </body>\n</opml>\n" | ||