nandi/gleanpublic Fork
⑂ 5057c55
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Add OPML feed import and export support

nandithebull committed 2026-09-21T20:53:36-07:00 Browse files
5057c55 parent: 3e88e98
modified app/lib/src/api/client.dart +31 -14
@@ -18,7 +18,7 @@ class ApiException implements Exception {
1818 /// loopback. That is why this class is a thin wrapper rather than a session.
1919 class GleanClient {
2020 GleanClient({required this.baseUrl, this.token = '', http.Client? client})
21- : _client = client ?? http.Client();
21+ : _client = client ?? http.Client();
2222
2323 final String baseUrl;
2424
@@ -61,15 +61,19 @@ class GleanClient {
6161 _decode(await _client.get(_uri(path, q), headers: _headers));
6262
6363 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+ );
6671
6772 // --- feeds ---
6873
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();
7377
7478 Future<int> unreadCount() async =>
7579 ((await _get('/unread')) as Map<String, dynamic>)['count'] as int? ?? 0;
@@ -82,9 +86,21 @@ class GleanClient {
8286 return (j['added'] as num?)?.toInt() ?? 0;
8387 }
8488
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+ );
88104
89105 Future<RefreshResult> refresh() async =>
90106 RefreshResult.fromJson(await _post('/refresh') as Map<String, dynamic>);
@@ -114,15 +130,16 @@ class GleanClient {
114130 _post('/read', {'id': '$id', if (!read) 'undo': '1'});
115131
116132 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>;
119136 return (j['unread'] as num?)?.toInt() ?? 0;
120137 }
121138
122139 /// Scrape the original page for feeds that only ship an excerpt.
123140 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>;
126143 return (j['content'] as String?) ?? '';
127144 }
128145
@@ -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 {
7979 await load();
8080 }
8181
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+
8290 Future<RefreshResult> refresh() async {
8391 final result = await client.refresh();
8492 await load();
@@ -105,7 +113,7 @@ class AppState extends ChangeNotifier {
105113
106114 class AppScope extends InheritedNotifier<AppState> {
107115 const AppScope({super.key, required AppState state, required super.child})
108- : super(notifier: state);
116+ : super(notifier: state);
109117
110118 static AppState of(BuildContext context) =>
111119 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 {
3838 final bool isRead;
3939
4040 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+ );
5252
5353 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+ );
6565 }
6666
6767 class Feed {
@@ -81,22 +81,22 @@ class Feed {
8181
8282 /// The "all feeds" row, which the server does not send.
8383 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+ );
9090
9191 bool get isAll => feedUrl.isEmpty;
9292
9393 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+ );
100100 }
101101
102102 /// What a refresh reports back: how many arrived, and which feeds failed.
@@ -107,9 +107,29 @@ class RefreshResult {
107107 final List<String> errors;
108108
109109 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+ );
115135 }
@@ -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 @@
11 import 'package:flutter/material.dart';
2+import 'package:flutter/services.dart';
23
34 import '../api/client.dart';
45 import '../app_state.dart';
@@ -71,11 +72,13 @@ class _HomeShellState extends State<HomeShell> {
7172 title: Text('Mark $scope read?'),
7273 actions: [
7374 TextButton(
74- onPressed: () => Navigator.pop(ctx, false),
75- child: const Text('Cancel')),
75+ onPressed: () => Navigator.pop(ctx, false),
76+ child: const Text('Cancel'),
77+ ),
7678 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+ ),
7982 ],
8083 ),
8184 );
@@ -92,11 +95,13 @@ class _HomeShellState extends State<HomeShell> {
9295 content: Text(f.title),
9396 actions: [
9497 TextButton(
95- onPressed: () => Navigator.pop(ctx, false),
96- child: const Text('Cancel')),
98+ onPressed: () => Navigator.pop(ctx, false),
99+ child: const Text('Cancel'),
100+ ),
97101 TextButton(
98- onPressed: () => Navigator.pop(ctx, true),
99- child: const Text('Unsubscribe')),
102+ onPressed: () => Navigator.pop(ctx, true),
103+ child: const Text('Unsubscribe'),
104+ ),
100105 ],
101106 ),
102107 );
@@ -107,6 +112,51 @@ class _HomeShellState extends State<HomeShell> {
107112 }
108113 }
109114
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+
110160 @override
111161 Widget build(BuildContext context) {
112162 final app = AppScope.of(context);
@@ -127,7 +177,8 @@ class _HomeShellState extends State<HomeShell> {
127177 ? const SizedBox(
128178 width: 18,
129179 height: 18,
130- child: CircularProgressIndicator(strokeWidth: 2))
180+ child: CircularProgressIndicator(strokeWidth: 2),
181+ )
131182 : const Icon(Icons.refresh),
132183 onPressed: _refreshing ? null : _refresh,
133184 ),
@@ -139,25 +190,37 @@ class _HomeShellState extends State<HomeShell> {
139190 child: Column(
140191 children: [
141192 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+ ],
151214 ),
152215 ),
153216 if (app.error != null)
154217 Padding(
155218 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+ ),
161224 ),
162225 Expanded(
163226 child: ListView(
@@ -165,8 +228,11 @@ class _HomeShellState extends State<HomeShell> {
165228 for (final f in app.sidebar)
166229 ListTile(
167230 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+ ),
170236 trailing: f.unread > 0
171237 ? GleanTag('${f.unread}', emphasis: !f.isAll)
172238 : null,
@@ -195,6 +261,44 @@ class _AddFeedDialog extends StatefulWidget {
195261 State<_AddFeedDialog> createState() => _AddFeedDialogState();
196262 }
197263
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+
198302 class _AddFeedDialogState extends State<_AddFeedDialog> {
199303 final _controller = TextEditingController();
200304
@@ -213,12 +317,16 @@ class _AddFeedDialogState extends State<_AddFeedDialog> {
213317 controller: _controller,
214318 autofocus: true,
215319 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+ ),
217323 onSubmitted: (v) => Navigator.pop(context, v.trim()),
218324 ),
219325 actions: [
220326 TextButton(
221- onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
327+ onPressed: () => Navigator.pop(context),
328+ child: const Text('Cancel'),
329+ ),
222330 TextButton(
223331 onPressed: () => Navigator.pop(context, _controller.text.trim()),
224332 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 @override160 @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- .textTheme221+ style: Theme.of(context).textTheme.bodySmall
159- .bodySmall222+ ?.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 > 0236 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 @@
2222 import std/[asyncdispatch, asynchttpserver, httpclient, json, options, os,
2323 strformat, strutils, times, uri]
2424 import reader/[articlestore, feedfetcher, feedparser, feedstore, gleandb,
25- scraper, sqlite]
25+ opml, scraper, sqlite]
2626
2727 const
2828 ## 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.} =
229229 items.add toJson(s, s.unreadCount)
230230 await jsonResponse(req, Http200, items)
231231
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+
232241 of "articles":
233242 if segs.len == 2:
234243 # One article, with its full text if we have it.
@@ -266,6 +275,33 @@ proc handle(r: Reader, req: Request) {.async.} =
266275
267276 of HttpPost:
268277 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+
269305 of "feeds":
270306 let url = param("url")
271307 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 const27 const
28 ## Baked in at compile time rather than read from disk, so the reader stays28 ## 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("&", "&amp;").replace("\"", "&quot;").replace("<", "&lt;")
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("&", "&amp;").replace("\"", "&quot;").replace("<", "&lt;")
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"