nandi/gleanpublic Fork
⑂ cd64c81
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

Require Flutter web bundle and improve feed management

nandithebull committed 2026-09-21T21:41:36-07:00 Browse files
cd64c81 parent: 5057c55
modified app/analysis_options.yaml +3 -0
@@ -15,6 +15,9 @@ analyzer:
1515 - android/**
1616 - web/**
1717 - linux/**
18+ - ios/**
19+ - windows/**
20+ - macos/**
1821
1922 linter:
2023 # The lint rules applied to this project can be customized in the
@@ -15,6 +15,9 @@ analyzer:
15 - android/**15 - android/**
16 - web/**16 - web/**
17 - linux/**17 - linux/**
18+ - ios/**
19+ - windows/**
20+ - macos/**
18 21
19 linter:22 linter:
20 # The lint rules applied to this project can be customized in the23 # The lint rules applied to this project can be customized in the
modified app/lib/src/api/client.dart +4 -1
@@ -36,7 +36,10 @@ class GleanClient {
3636
3737 Uri _uri(String path, [Map<String, String>? query]) {
3838 final q = query?.entries.where((e) => e.value.isNotEmpty);
39- return Uri.parse('$baseUrl$path').replace(
39+ // The web app is served by the reader. An empty base means same origin,
40+ // rather than the browser's own 127.0.0.1 (which is never the Modal app).
41+ final base = baseUrl.isEmpty ? Uri.base.resolve(path) : Uri.parse('$baseUrl$path');
42+ return base.replace(
4043 queryParameters: q == null || q.isEmpty ? null : Map.fromEntries(q),
4144 );
4245 }
@@ -36,7 +36,10 @@ class GleanClient {
36 36
37 Uri _uri(String path, [Map<String, String>? query]) {37 Uri _uri(String path, [Map<String, String>? query]) {
38 final q = query?.entries.where((e) => e.value.isNotEmpty);38 final q = query?.entries.where((e) => e.value.isNotEmpty);
39- return Uri.parse('$baseUrl$path').replace(39+ // The web app is served by the reader. An empty base means same origin,
40+ // rather than the browser's own 127.0.0.1 (which is never the Modal app).
41+ final base = baseUrl.isEmpty ? Uri.base.resolve(path) : Uri.parse('$baseUrl$path');
42+ return base.replace(
40 queryParameters: q == null || q.isEmpty ? null : Map.fromEntries(q),43 queryParameters: q == null || q.isEmpty ? null : Map.fromEntries(q),
41 );44 );
42 }45 }
modified app/lib/src/app_state.dart +9 -4
@@ -1,3 +1,4 @@
1+import 'package:flutter/foundation.dart' show kIsWeb;
12 import 'package:flutter/widgets.dart';
23
34 import 'api/client.dart';
@@ -12,11 +13,15 @@ import 'models/models.dart';
1213 /// The web build is served *by* the reader, so it leaves this empty and uses
1314 /// relative paths: same origin, which means no CORS preflight and the
1415 /// reader's own cookie on every request.
15-const kDefaultBaseUrl = String.fromEnvironment(
16+const kConfiguredBaseUrl = String.fromEnvironment(
1617 'GLEAN_BASE_URL',
17- defaultValue: 'http://127.0.0.1:8080',
18+ defaultValue: '',
1819 );
1920
21+String get kDefaultBaseUrl => kConfiguredBaseUrl.isNotEmpty
22+ ? kConfiguredBaseUrl
23+ : (kIsWeb ? '' : 'http://127.0.0.1:8080');
24+
2025 /// Shared secret, for a reader running with GLEAN_TOKEN set.
2126 ///
2227 /// Empty by default, which is right for a local reader and for the web build
@@ -31,10 +36,10 @@ const kToken = String.fromEnvironment('GLEAN_TOKEN', defaultValue: '');
3136 /// not set them.
3237 class AppState extends ChangeNotifier {
3338 AppState({
34- String baseUrl = kDefaultBaseUrl,
39+ String? baseUrl,
3540 String token = kToken,
3641 GleanClient? client,
37- }) : client = client ?? GleanClient(baseUrl: baseUrl, token: token);
42+ }) : client = client ?? GleanClient(baseUrl: baseUrl ?? kDefaultBaseUrl, token: token);
3843
3944 final GleanClient client;
4045
@@ -1,3 +1,4 @@
1+import 'package:flutter/foundation.dart' show kIsWeb;
1 import 'package:flutter/widgets.dart';2 import 'package:flutter/widgets.dart';
2 3
3 import 'api/client.dart';4 import 'api/client.dart';
@@ -12,11 +13,15 @@ import 'models/models.dart';
12 /// The web build is served *by* the reader, so it leaves this empty and uses13 /// The web build is served *by* the reader, so it leaves this empty and uses
13 /// relative paths: same origin, which means no CORS preflight and the14 /// relative paths: same origin, which means no CORS preflight and the
14 /// reader's own cookie on every request.15 /// reader's own cookie on every request.
15-const kDefaultBaseUrl = String.fromEnvironment(16+const kConfiguredBaseUrl = String.fromEnvironment(
16 'GLEAN_BASE_URL',17 'GLEAN_BASE_URL',
17- defaultValue: 'http://127.0.0.1:8080',18+ defaultValue: '',
18 );19 );
19 20
21+String get kDefaultBaseUrl => kConfiguredBaseUrl.isNotEmpty
22+ ? kConfiguredBaseUrl
23+ : (kIsWeb ? '' : 'http://127.0.0.1:8080');
24+
20 /// Shared secret, for a reader running with GLEAN_TOKEN set.25 /// Shared secret, for a reader running with GLEAN_TOKEN set.
21 ///26 ///
22 /// Empty by default, which is right for a local reader and for the web build27 /// Empty by default, which is right for a local reader and for the web build
@@ -31,10 +36,10 @@ const kToken = String.fromEnvironment('GLEAN_TOKEN', defaultValue: '');
31 /// not set them.36 /// not set them.
32 class AppState extends ChangeNotifier {37 class AppState extends ChangeNotifier {
33 AppState({38 AppState({
34- String baseUrl = kDefaultBaseUrl,39+ String? baseUrl,
35 String token = kToken,40 String token = kToken,
36 GleanClient? client,41 GleanClient? client,
37- }) : client = client ?? GleanClient(baseUrl: baseUrl, token: token);42+ }) : client = client ?? GleanClient(baseUrl: baseUrl ?? kDefaultBaseUrl, token: token);
38 43
39 final GleanClient client;44 final GleanClient client;
40 45
added app/lib/src/screens/feed_manager_screen.dart +292 -0
new file mode 100644
@@ -0,0 +1,292 @@
1+import 'package:flutter/material.dart';
2+import 'package:flutter/services.dart';
3+
4+import '../api/client.dart';
5+import '../app_state.dart';
6+import '../models/models.dart';
7+import '../theme.dart';
8+import '../widgets/common.dart';
9+
10+/// A dedicated, visible place to maintain subscriptions and OPML backups.
11+class FeedManagerScreen extends StatefulWidget {
12+ const FeedManagerScreen({super.key, required this.onClose});
13+
14+ final ValueChanged<bool> onClose;
15+
16+ @override
17+ State<FeedManagerScreen> createState() => _FeedManagerScreenState();
18+}
19+
20+class _FeedManagerScreenState extends State<FeedManagerScreen> {
21+ final _url = TextEditingController();
22+ final _filter = TextEditingController();
23+ bool _adding = false;
24+ bool _changed = false;
25+
26+ @override
27+ void dispose() {
28+ _url.dispose();
29+ _filter.dispose();
30+ super.dispose();
31+ }
32+
33+ Future<void> _add() async {
34+ final url = _url.text.trim();
35+ if (url.isEmpty || _adding) return;
36+ setState(() => _adding = true);
37+ try {
38+ await AppScope.read(context).addFeed(url);
39+ _url.clear();
40+ _changed = true;
41+ if (mounted) showToast(context, 'Feed added');
42+ } on ApiException catch (e) {
43+ if (mounted) showToast(context, e.message);
44+ } finally {
45+ if (mounted) setState(() => _adding = false);
46+ }
47+ }
48+
49+ Future<void> _remove(Feed feed) async {
50+ final confirmed = await showDialog<bool>(
51+ context: context,
52+ builder: (dialogContext) => AlertDialog(
53+ shape: const RoundedRectangleBorder(),
54+ title: const Text('Remove feed?'),
55+ content: Text('Stop following ${feed.title}?'),
56+ actions: [
57+ TextButton(
58+ onPressed: () => Navigator.pop(dialogContext, false),
59+ child: const Text('Keep'),
60+ ),
61+ TextButton(
62+ onPressed: () => Navigator.pop(dialogContext, true),
63+ child: const Text('Remove'),
64+ ),
65+ ],
66+ ),
67+ );
68+ if (confirmed != true || !mounted) return;
69+ try {
70+ await AppScope.read(context).removeFeed(feed.feedUrl);
71+ _changed = true;
72+ if (mounted) showToast(context, 'Feed removed');
73+ } on ApiException catch (e) {
74+ if (mounted) showToast(context, e.message);
75+ }
76+ }
77+
78+ Future<void> _importOpml() async {
79+ final source = await showDialog<String>(
80+ context: context,
81+ builder: (_) => const _ImportOpmlDialog(),
82+ );
83+ if (source == null || source.trim().isEmpty || !mounted) return;
84+ try {
85+ final result = await AppScope.read(context).importOpml(source);
86+ _changed = result.added > 0;
87+ if (mounted) {
88+ showToast(
89+ context,
90+ '${result.added} feed(s) added${result.errors.isEmpty ? '' : ', ${result.errors.length} failed'}',
91+ );
92+ }
93+ } on ApiException catch (e) {
94+ if (mounted) showToast(context, e.message);
95+ }
96+ }
97+
98+ Future<void> _exportOpml() async {
99+ try {
100+ final opml = await AppScope.read(context).exportOpml();
101+ await Clipboard.setData(ClipboardData(text: opml));
102+ if (mounted) showToast(context, 'OPML backup copied to clipboard');
103+ } on ApiException catch (e) {
104+ if (mounted) showToast(context, e.message);
105+ }
106+ }
107+
108+ @override
109+ Widget build(BuildContext context) {
110+ final app = AppScope.of(context);
111+ final c = GleanColors.of(context);
112+ final filter = _filter.text.trim().toLowerCase();
113+ final feeds = app.feeds.where((feed) {
114+ return filter.isEmpty ||
115+ feed.title.toLowerCase().contains(filter) ||
116+ feed.feedUrl.toLowerCase().contains(filter);
117+ }).toList();
118+
119+ return Scaffold(
120+ appBar: AppBar(
121+ leading: IconButton(
122+ tooltip: 'Back',
123+ icon: const Icon(Icons.arrow_back),
124+ onPressed: () => widget.onClose(_changed),
125+ ),
126+ title: const Text('Manage feeds'),
127+ ),
128+ body: ListView(
129+ padding: const EdgeInsets.all(16),
130+ children: [
131+ Text(
132+ 'Add a subscription',
133+ style: Theme.of(context).textTheme.titleMedium,
134+ ),
135+ const SizedBox(height: 8),
136+ Row(
137+ crossAxisAlignment: CrossAxisAlignment.start,
138+ children: [
139+ Expanded(
140+ child: TextField(
141+ controller: _url,
142+ keyboardType: TextInputType.url,
143+ textInputAction: TextInputAction.done,
144+ onSubmitted: (_) => _add(),
145+ decoration: const InputDecoration(
146+ hintText: 'https://example.com/feed.xml',
147+ ),
148+ ),
149+ ),
150+ const SizedBox(width: 8),
151+ FilledButton.icon(
152+ onPressed: _adding ? null : _add,
153+ icon: _adding
154+ ? const SizedBox(
155+ width: 16,
156+ height: 16,
157+ child: CircularProgressIndicator(strokeWidth: 2),
158+ )
159+ : const Icon(Icons.add),
160+ label: const Text('Add'),
161+ ),
162+ ],
163+ ),
164+ const SizedBox(height: 28),
165+ Row(
166+ children: [
167+ Text(
168+ 'Subscriptions',
169+ style: Theme.of(context).textTheme.titleMedium,
170+ ),
171+ const Spacer(),
172+ Text(
173+ '${app.feeds.length}',
174+ style: Theme.of(context).textTheme.bodySmall,
175+ ),
176+ ],
177+ ),
178+ const SizedBox(height: 8),
179+ TextField(
180+ controller: _filter,
181+ onChanged: (_) => setState(() {}),
182+ decoration: const InputDecoration(
183+ prefixIcon: Icon(Icons.search),
184+ hintText: 'Filter feeds',
185+ ),
186+ ),
187+ const SizedBox(height: 8),
188+ if (feeds.isEmpty)
189+ Padding(
190+ padding: const EdgeInsets.symmetric(vertical: 32),
191+ child: Text(
192+ filter.isEmpty ? 'No subscriptions yet.' : 'No matching feeds.',
193+ textAlign: TextAlign.center,
194+ style: Theme.of(context).textTheme.bodySmall,
195+ ),
196+ )
197+ else
198+ ...feeds.map(
199+ (feed) => Container(
200+ margin: const EdgeInsets.only(bottom: 8),
201+ decoration: BoxDecoration(
202+ border: Border.all(color: c.border, width: 2),
203+ color: c.surface,
204+ ),
205+ child: ListTile(
206+ contentPadding: const EdgeInsets.only(left: 14, right: 4),
207+ title: Text(
208+ feed.title,
209+ maxLines: 1,
210+ overflow: TextOverflow.ellipsis,
211+ ),
212+ subtitle: Text(
213+ feed.feedUrl,
214+ maxLines: 1,
215+ overflow: TextOverflow.ellipsis,
216+ ),
217+ trailing: Row(
218+ mainAxisSize: MainAxisSize.min,
219+ children: [
220+ if (feed.unread > 0) Text('${feed.unread} unread'),
221+ IconButton(
222+ tooltip: 'Remove ${feed.title}',
223+ icon: Icon(Icons.delete_outline, color: c.danger),
224+ onPressed: () => _remove(feed),
225+ ),
226+ ],
227+ ),
228+ ),
229+ ),
230+ ),
231+ const SizedBox(height: 28),
232+ Text(
233+ 'Backup & restore',
234+ style: Theme.of(context).textTheme.titleMedium,
235+ ),
236+ const SizedBox(height: 8),
237+ OutlinedButton.icon(
238+ onPressed: _importOpml,
239+ icon: const Icon(Icons.upload_file),
240+ label: const Text('Import OPML'),
241+ ),
242+ const SizedBox(height: 8),
243+ OutlinedButton.icon(
244+ onPressed: _exportOpml,
245+ icon: const Icon(Icons.content_copy),
246+ label: const Text('Copy OPML backup'),
247+ ),
248+ ],
249+ ),
250+ );
251+ }
252+}
253+
254+class _ImportOpmlDialog extends StatefulWidget {
255+ const _ImportOpmlDialog();
256+
257+ @override
258+ State<_ImportOpmlDialog> createState() => _ImportOpmlDialogState();
259+}
260+
261+class _ImportOpmlDialogState extends State<_ImportOpmlDialog> {
262+ final _controller = TextEditingController();
263+
264+ @override
265+ void dispose() {
266+ _controller.dispose();
267+ super.dispose();
268+ }
269+
270+ @override
271+ Widget build(BuildContext context) => AlertDialog(
272+ shape: const RoundedRectangleBorder(),
273+ title: const Text('Import OPML'),
274+ content: TextField(
275+ controller: _controller,
276+ autofocus: true,
277+ minLines: 8,
278+ maxLines: 14,
279+ decoration: const InputDecoration(hintText: 'Paste your OPML here'),
280+ ),
281+ actions: [
282+ TextButton(
283+ onPressed: () => Navigator.pop(context),
284+ child: const Text('Cancel'),
285+ ),
286+ TextButton(
287+ onPressed: () => Navigator.pop(context, _controller.text),
288+ child: const Text('Import'),
289+ ),
290+ ],
291+ );
292+}
new file mode 100644
@@ -0,0 +1,292 @@
1+import 'package:flutter/material.dart';
2+import 'package:flutter/services.dart';
3+
4+import '../api/client.dart';
5+import '../app_state.dart';
6+import '../models/models.dart';
7+import '../theme.dart';
8+import '../widgets/common.dart';
9+
10+/// A dedicated, visible place to maintain subscriptions and OPML backups.
11+class FeedManagerScreen extends StatefulWidget {
12+ const FeedManagerScreen({super.key, required this.onClose});
13+
14+ final ValueChanged<bool> onClose;
15+
16+ @override
17+ State<FeedManagerScreen> createState() => _FeedManagerScreenState();
18+}
19+
20+class _FeedManagerScreenState extends State<FeedManagerScreen> {
21+ final _url = TextEditingController();
22+ final _filter = TextEditingController();
23+ bool _adding = false;
24+ bool _changed = false;
25+
26+ @override
27+ void dispose() {
28+ _url.dispose();
29+ _filter.dispose();
30+ super.dispose();
31+ }
32+
33+ Future<void> _add() async {
34+ final url = _url.text.trim();
35+ if (url.isEmpty || _adding) return;
36+ setState(() => _adding = true);
37+ try {
38+ await AppScope.read(context).addFeed(url);
39+ _url.clear();
40+ _changed = true;
41+ if (mounted) showToast(context, 'Feed added');
42+ } on ApiException catch (e) {
43+ if (mounted) showToast(context, e.message);
44+ } finally {
45+ if (mounted) setState(() => _adding = false);
46+ }
47+ }
48+
49+ Future<void> _remove(Feed feed) async {
50+ final confirmed = await showDialog<bool>(
51+ context: context,
52+ builder: (dialogContext) => AlertDialog(
53+ shape: const RoundedRectangleBorder(),
54+ title: const Text('Remove feed?'),
55+ content: Text('Stop following ${feed.title}?'),
56+ actions: [
57+ TextButton(
58+ onPressed: () => Navigator.pop(dialogContext, false),
59+ child: const Text('Keep'),
60+ ),
61+ TextButton(
62+ onPressed: () => Navigator.pop(dialogContext, true),
63+ child: const Text('Remove'),
64+ ),
65+ ],
66+ ),
67+ );
68+ if (confirmed != true || !mounted) return;
69+ try {
70+ await AppScope.read(context).removeFeed(feed.feedUrl);
71+ _changed = true;
72+ if (mounted) showToast(context, 'Feed removed');
73+ } on ApiException catch (e) {
74+ if (mounted) showToast(context, e.message);
75+ }
76+ }
77+
78+ Future<void> _importOpml() async {
79+ final source = await showDialog<String>(
80+ context: context,
81+ builder: (_) => const _ImportOpmlDialog(),
82+ );
83+ if (source == null || source.trim().isEmpty || !mounted) return;
84+ try {
85+ final result = await AppScope.read(context).importOpml(source);
86+ _changed = result.added > 0;
87+ if (mounted) {
88+ showToast(
89+ context,
90+ '${result.added} feed(s) added${result.errors.isEmpty ? '' : ', ${result.errors.length} failed'}',
91+ );
92+ }
93+ } on ApiException catch (e) {
94+ if (mounted) showToast(context, e.message);
95+ }
96+ }
97+
98+ Future<void> _exportOpml() async {
99+ try {
100+ final opml = await AppScope.read(context).exportOpml();
101+ await Clipboard.setData(ClipboardData(text: opml));
102+ if (mounted) showToast(context, 'OPML backup copied to clipboard');
103+ } on ApiException catch (e) {
104+ if (mounted) showToast(context, e.message);
105+ }
106+ }
107+
108+ @override
109+ Widget build(BuildContext context) {
110+ final app = AppScope.of(context);
111+ final c = GleanColors.of(context);
112+ final filter = _filter.text.trim().toLowerCase();
113+ final feeds = app.feeds.where((feed) {
114+ return filter.isEmpty ||
115+ feed.title.toLowerCase().contains(filter) ||
116+ feed.feedUrl.toLowerCase().contains(filter);
117+ }).toList();
118+
119+ return Scaffold(
120+ appBar: AppBar(
121+ leading: IconButton(
122+ tooltip: 'Back',
123+ icon: const Icon(Icons.arrow_back),
124+ onPressed: () => widget.onClose(_changed),
125+ ),
126+ title: const Text('Manage feeds'),
127+ ),
128+ body: ListView(
129+ padding: const EdgeInsets.all(16),
130+ children: [
131+ Text(
132+ 'Add a subscription',
133+ style: Theme.of(context).textTheme.titleMedium,
134+ ),
135+ const SizedBox(height: 8),
136+ Row(
137+ crossAxisAlignment: CrossAxisAlignment.start,
138+ children: [
139+ Expanded(
140+ child: TextField(
141+ controller: _url,
142+ keyboardType: TextInputType.url,
143+ textInputAction: TextInputAction.done,
144+ onSubmitted: (_) => _add(),
145+ decoration: const InputDecoration(
146+ hintText: 'https://example.com/feed.xml',
147+ ),
148+ ),
149+ ),
150+ const SizedBox(width: 8),
151+ FilledButton.icon(
152+ onPressed: _adding ? null : _add,
153+ icon: _adding
154+ ? const SizedBox(
155+ width: 16,
156+ height: 16,
157+ child: CircularProgressIndicator(strokeWidth: 2),
158+ )
159+ : const Icon(Icons.add),
160+ label: const Text('Add'),
161+ ),
162+ ],
163+ ),
164+ const SizedBox(height: 28),
165+ Row(
166+ children: [
167+ Text(
168+ 'Subscriptions',
169+ style: Theme.of(context).textTheme.titleMedium,
170+ ),
171+ const Spacer(),
172+ Text(
173+ '${app.feeds.length}',
174+ style: Theme.of(context).textTheme.bodySmall,
175+ ),
176+ ],
177+ ),
178+ const SizedBox(height: 8),
179+ TextField(
180+ controller: _filter,
181+ onChanged: (_) => setState(() {}),
182+ decoration: const InputDecoration(
183+ prefixIcon: Icon(Icons.search),
184+ hintText: 'Filter feeds',
185+ ),
186+ ),
187+ const SizedBox(height: 8),
188+ if (feeds.isEmpty)
189+ Padding(
190+ padding: const EdgeInsets.symmetric(vertical: 32),
191+ child: Text(
192+ filter.isEmpty ? 'No subscriptions yet.' : 'No matching feeds.',
193+ textAlign: TextAlign.center,
194+ style: Theme.of(context).textTheme.bodySmall,
195+ ),
196+ )
197+ else
198+ ...feeds.map(
199+ (feed) => Container(
200+ margin: const EdgeInsets.only(bottom: 8),
201+ decoration: BoxDecoration(
202+ border: Border.all(color: c.border, width: 2),
203+ color: c.surface,
204+ ),
205+ child: ListTile(
206+ contentPadding: const EdgeInsets.only(left: 14, right: 4),
207+ title: Text(
208+ feed.title,
209+ maxLines: 1,
210+ overflow: TextOverflow.ellipsis,
211+ ),
212+ subtitle: Text(
213+ feed.feedUrl,
214+ maxLines: 1,
215+ overflow: TextOverflow.ellipsis,
216+ ),
217+ trailing: Row(
218+ mainAxisSize: MainAxisSize.min,
219+ children: [
220+ if (feed.unread > 0) Text('${feed.unread} unread'),
221+ IconButton(
222+ tooltip: 'Remove ${feed.title}',
223+ icon: Icon(Icons.delete_outline, color: c.danger),
224+ onPressed: () => _remove(feed),
225+ ),
226+ ],
227+ ),
228+ ),
229+ ),
230+ ),
231+ const SizedBox(height: 28),
232+ Text(
233+ 'Backup & restore',
234+ style: Theme.of(context).textTheme.titleMedium,
235+ ),
236+ const SizedBox(height: 8),
237+ OutlinedButton.icon(
238+ onPressed: _importOpml,
239+ icon: const Icon(Icons.upload_file),
240+ label: const Text('Import OPML'),
241+ ),
242+ const SizedBox(height: 8),
243+ OutlinedButton.icon(
244+ onPressed: _exportOpml,
245+ icon: const Icon(Icons.content_copy),
246+ label: const Text('Copy OPML backup'),
247+ ),
248+ ],
249+ ),
250+ );
251+ }
252+}
253+
254+class _ImportOpmlDialog extends StatefulWidget {
255+ const _ImportOpmlDialog();
256+
257+ @override
258+ State<_ImportOpmlDialog> createState() => _ImportOpmlDialogState();
259+}
260+
261+class _ImportOpmlDialogState extends State<_ImportOpmlDialog> {
262+ final _controller = TextEditingController();
263+
264+ @override
265+ void dispose() {
266+ _controller.dispose();
267+ super.dispose();
268+ }
269+
270+ @override
271+ Widget build(BuildContext context) => AlertDialog(
272+ shape: const RoundedRectangleBorder(),
273+ title: const Text('Import OPML'),
274+ content: TextField(
275+ controller: _controller,
276+ autofocus: true,
277+ minLines: 8,
278+ maxLines: 14,
279+ decoration: const InputDecoration(hintText: 'Paste your OPML here'),
280+ ),
281+ actions: [
282+ TextButton(
283+ onPressed: () => Navigator.pop(context),
284+ child: const Text('Cancel'),
285+ ),
286+ TextButton(
287+ onPressed: () => Navigator.pop(context, _controller.text),
288+ child: const Text('Import'),
289+ ),
290+ ],
291+ );
292+}
modified app/lib/src/screens/home_shell.dart +24 -171
@@ -1,5 +1,4 @@
11 import 'package:flutter/material.dart';
2-import 'package:flutter/services.dart';
32
43 import '../api/client.dart';
54 import '../app_state.dart';
@@ -7,6 +6,7 @@ import '../models/models.dart';
76 import '../theme.dart';
87 import '../widgets/common.dart';
98 import 'articles_screen.dart';
9+import 'feed_manager_screen.dart';
1010
1111 /// Articles, with the feed list in a drawer.
1212 ///
@@ -23,6 +23,7 @@ class HomeShell extends StatefulWidget {
2323 class _HomeShellState extends State<HomeShell> {
2424 Feed _selected = Feed.all(0);
2525 bool _refreshing = false;
26+ bool _managingFeeds = false;
2627
2728 @override
2829 void initState() {
@@ -32,19 +33,6 @@ class _HomeShellState extends State<HomeShell> {
3233 });
3334 }
3435
35- Future<void> _addFeed() async {
36- final url = await showDialog<String>(
37- context: context,
38- builder: (_) => const _AddFeedDialog(),
39- );
40- if (url == null || url.isEmpty || !mounted) return;
41- try {
42- await AppScope.read(context).addFeed(url);
43- } on ApiException catch (e) {
44- if (mounted) showToast(context, e.message);
45- }
46- }
47-
4836 Future<void> _refresh() async {
4937 setState(() => _refreshing = true);
5038 try {
@@ -86,79 +74,27 @@ class _HomeShellState extends State<HomeShell> {
8674 await AppScope.read(context).markAllRead(feedUrl: _selected.feedUrl);
8775 }
8876
89- Future<void> _remove(Feed f) async {
90- final ok = await showDialog<bool>(
91- context: context,
92- builder: (ctx) => AlertDialog(
93- shape: const RoundedRectangleBorder(),
94- title: const Text('Unsubscribe?'),
95- content: Text(f.title),
96- actions: [
97- TextButton(
98- onPressed: () => Navigator.pop(ctx, false),
99- child: const Text('Cancel'),
100- ),
101- TextButton(
102- onPressed: () => Navigator.pop(ctx, true),
103- child: const Text('Unsubscribe'),
104- ),
105- ],
106- ),
107- );
108- if (ok != true || !mounted) return;
109- await AppScope.read(context).removeFeed(f.feedUrl);
110- if (mounted && _selected.feedUrl == f.feedUrl) {
111- setState(() => _selected = Feed.all(0));
112- }
113- }
77+ void _manageFeeds() => setState(() => _managingFeeds = true);
11478
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);
79+ void _closeManageFeeds(bool changed) {
80+ if (!changed) {
81+ setState(() => _managingFeeds = false);
82+ return;
15783 }
84+ final currentExists =
85+ _selected.isAll ||
86+ AppScope.read(context).feeds.any((f) => f.feedUrl == _selected.feedUrl);
87+ setState(() {
88+ _managingFeeds = false;
89+ if (!currentExists) _selected = Feed.all(0);
90+ });
15891 }
15992
16093 @override
16194 Widget build(BuildContext context) {
95+ if (_managingFeeds) {
96+ return FeedManagerScreen(onClose: _closeManageFeeds);
97+ }
16298 final app = AppScope.of(context);
16399 final c = GleanColors.of(context);
164100
@@ -166,6 +102,11 @@ class _HomeShellState extends State<HomeShell> {
166102 appBar: AppBar(
167103 title: Text(_selected.isAll ? 'glean' : _selected.title),
168104 actions: [
105+ IconButton(
106+ tooltip: 'Manage feeds',
107+ icon: const Icon(Icons.rss_feed),
108+ onPressed: _manageFeeds,
109+ ),
169110 IconButton(
170111 tooltip: 'Mark all read',
171112 icon: const Icon(Icons.done_all),
@@ -198,16 +139,11 @@ class _HomeShellState extends State<HomeShell> {
198139 mainAxisSize: MainAxisSize.min,
199140 children: [
200141 IconButton(
201- icon: const Icon(Icons.more_horiz),
142+ icon: const Icon(Icons.tune),
202143 tooltip: 'Manage feeds',
203- onPressed: _manageFeeds,
204- ),
205- IconButton(
206- icon: const Icon(Icons.add),
207- tooltip: 'Add feed',
208144 onPressed: () {
209145 Navigator.of(context).pop();
210- _addFeed();
146+ _manageFeeds();
211147 },
212148 ),
213149 ],
@@ -236,7 +172,6 @@ class _HomeShellState extends State<HomeShell> {
236172 trailing: f.unread > 0
237173 ? GleanTag('${f.unread}', emphasis: !f.isAll)
238174 : null,
239- onLongPress: f.isAll ? null : () => _remove(f),
240175 onTap: () {
241176 setState(() => _selected = f);
242177 Navigator.of(context).pop();
@@ -253,85 +188,3 @@ class _HomeShellState extends State<HomeShell> {
253188 );
254189 }
255190 }
256-
257-class _AddFeedDialog extends StatefulWidget {
258- const _AddFeedDialog();
259-
260- @override
261- State<_AddFeedDialog> createState() => _AddFeedDialogState();
262-}
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-
302-class _AddFeedDialogState extends State<_AddFeedDialog> {
303- final _controller = TextEditingController();
304-
305- @override
306- void dispose() {
307- _controller.dispose();
308- super.dispose();
309- }
310-
311- @override
312- Widget build(BuildContext context) {
313- return AlertDialog(
314- shape: const RoundedRectangleBorder(),
315- title: const Text('Add feed'),
316- content: TextField(
317- controller: _controller,
318- autofocus: true,
319- keyboardType: TextInputType.url,
320- decoration: const InputDecoration(
321- hintText: 'https://example.com/feed.xml',
322- ),
323- onSubmitted: (v) => Navigator.pop(context, v.trim()),
324- ),
325- actions: [
326- TextButton(
327- onPressed: () => Navigator.pop(context),
328- child: const Text('Cancel'),
329- ),
330- TextButton(
331- onPressed: () => Navigator.pop(context, _controller.text.trim()),
332- child: const Text('Add'),
333- ),
334- ],
335- );
336- }
337-}
@@ -1,5 +1,4 @@
1 import 'package:flutter/material.dart';1 import 'package:flutter/material.dart';
2-import 'package:flutter/services.dart';
3 2
4 import '../api/client.dart';3 import '../api/client.dart';
5 import '../app_state.dart';4 import '../app_state.dart';
@@ -7,6 +6,7 @@ import '../models/models.dart';
7 import '../theme.dart';6 import '../theme.dart';
8 import '../widgets/common.dart';7 import '../widgets/common.dart';
9 import 'articles_screen.dart';8 import 'articles_screen.dart';
9+import 'feed_manager_screen.dart';
10 10
11 /// Articles, with the feed list in a drawer.11 /// Articles, with the feed list in a drawer.
12 ///12 ///
@@ -23,6 +23,7 @@ class HomeShell extends StatefulWidget {
23 class _HomeShellState extends State<HomeShell> {23 class _HomeShellState extends State<HomeShell> {
24 Feed _selected = Feed.all(0);24 Feed _selected = Feed.all(0);
25 bool _refreshing = false;25 bool _refreshing = false;
26+ bool _managingFeeds = false;
26 27
27 @override28 @override
28 void initState() {29 void initState() {
@@ -32,19 +33,6 @@ class _HomeShellState extends State<HomeShell> {
32 });33 });
33 }34 }
34 35
35- Future<void> _addFeed() async {
36- final url = await showDialog<String>(
37- context: context,
38- builder: (_) => const _AddFeedDialog(),
39- );
40- if (url == null || url.isEmpty || !mounted) return;
41- try {
42- await AppScope.read(context).addFeed(url);
43- } on ApiException catch (e) {
44- if (mounted) showToast(context, e.message);
45- }
46- }
47-
48 Future<void> _refresh() async {36 Future<void> _refresh() async {
49 setState(() => _refreshing = true);37 setState(() => _refreshing = true);
50 try {38 try {
@@ -86,79 +74,27 @@ class _HomeShellState extends State<HomeShell> {
86 await AppScope.read(context).markAllRead(feedUrl: _selected.feedUrl);74 await AppScope.read(context).markAllRead(feedUrl: _selected.feedUrl);
87 }75 }
88 76
89- Future<void> _remove(Feed f) async {77+ void _manageFeeds() => setState(() => _managingFeeds = true);
90- final ok = await showDialog<bool>(
91- context: context,
92- builder: (ctx) => AlertDialog(
93- shape: const RoundedRectangleBorder(),
94- title: const Text('Unsubscribe?'),
95- content: Text(f.title),
96- actions: [
97- TextButton(
98- onPressed: () => Navigator.pop(ctx, false),
99- child: const Text('Cancel'),
100- ),
101- TextButton(
102- onPressed: () => Navigator.pop(ctx, true),
103- child: const Text('Unsubscribe'),
104- ),
105- ],
106- ),
107- );
108- if (ok != true || !mounted) return;
109- await AppScope.read(context).removeFeed(f.feedUrl);
110- if (mounted && _selected.feedUrl == f.feedUrl) {
111- setState(() => _selected = Feed.all(0));
112- }
113- }
114 78
115- Future<void> _manageFeeds() async {79+ void _closeManageFeeds(bool changed) {
116- final action = await showModalBottomSheet<String>(80+ if (!changed) {
117- context: context,81+ setState(() => _managingFeeds = false);
118- builder: (ctx) => SafeArea(82+ return;
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 }83 }
84+ final currentExists =
85+ _selected.isAll ||
86+ AppScope.read(context).feeds.any((f) => f.feedUrl == _selected.feedUrl);
87+ setState(() {
88+ _managingFeeds = false;
89+ if (!currentExists) _selected = Feed.all(0);
90+ });
158 }91 }
159 92
160 @override93 @override
161 Widget build(BuildContext context) {94 Widget build(BuildContext context) {
95+ if (_managingFeeds) {
96+ return FeedManagerScreen(onClose: _closeManageFeeds);
97+ }
162 final app = AppScope.of(context);98 final app = AppScope.of(context);
163 final c = GleanColors.of(context);99 final c = GleanColors.of(context);
164 100
@@ -166,6 +102,11 @@ class _HomeShellState extends State<HomeShell> {
166 appBar: AppBar(102 appBar: AppBar(
167 title: Text(_selected.isAll ? 'glean' : _selected.title),103 title: Text(_selected.isAll ? 'glean' : _selected.title),
168 actions: [104 actions: [
105+ IconButton(
106+ tooltip: 'Manage feeds',
107+ icon: const Icon(Icons.rss_feed),
108+ onPressed: _manageFeeds,
109+ ),
169 IconButton(110 IconButton(
170 tooltip: 'Mark all read',111 tooltip: 'Mark all read',
171 icon: const Icon(Icons.done_all),112 icon: const Icon(Icons.done_all),
@@ -198,16 +139,11 @@ class _HomeShellState extends State<HomeShell> {
198 mainAxisSize: MainAxisSize.min,139 mainAxisSize: MainAxisSize.min,
199 children: [140 children: [
200 IconButton(141 IconButton(
201- icon: const Icon(Icons.more_horiz),142+ icon: const Icon(Icons.tune),
202 tooltip: 'Manage feeds',143 tooltip: 'Manage feeds',
203- onPressed: _manageFeeds,
204- ),
205- IconButton(
206- icon: const Icon(Icons.add),
207- tooltip: 'Add feed',
208 onPressed: () {144 onPressed: () {
209 Navigator.of(context).pop();145 Navigator.of(context).pop();
210- _addFeed();146+ _manageFeeds();
211 },147 },
212 ),148 ),
213 ],149 ],
@@ -236,7 +172,6 @@ class _HomeShellState extends State<HomeShell> {
236 trailing: f.unread > 0172 trailing: f.unread > 0
237 ? GleanTag('${f.unread}', emphasis: !f.isAll)173 ? GleanTag('${f.unread}', emphasis: !f.isAll)
238 : null,174 : null,
239- onLongPress: f.isAll ? null : () => _remove(f),
240 onTap: () {175 onTap: () {
241 setState(() => _selected = f);176 setState(() => _selected = f);
242 Navigator.of(context).pop();177 Navigator.of(context).pop();
@@ -253,85 +188,3 @@ class _HomeShellState extends State<HomeShell> {
253 );188 );
254 }189 }
255 }190 }
256-
257-class _AddFeedDialog extends StatefulWidget {
258- const _AddFeedDialog();
259-
260- @override
261- State<_AddFeedDialog> createState() => _AddFeedDialogState();
262-}
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-
302-class _AddFeedDialogState extends State<_AddFeedDialog> {
303- final _controller = TextEditingController();
304-
305- @override
306- void dispose() {
307- _controller.dispose();
308- super.dispose();
309- }
310-
311- @override
312- Widget build(BuildContext context) {
313- return AlertDialog(
314- shape: const RoundedRectangleBorder(),
315- title: const Text('Add feed'),
316- content: TextField(
317- controller: _controller,
318- autofocus: true,
319- keyboardType: TextInputType.url,
320- decoration: const InputDecoration(
321- hintText: 'https://example.com/feed.xml',
322- ),
323- onSubmitted: (v) => Navigator.pop(context, v.trim()),
324- ),
325- actions: [
326- TextButton(
327- onPressed: () => Navigator.pop(context),
328- child: const Text('Cancel'),
329- ),
330- TextButton(
331- onPressed: () => Navigator.pop(context, _controller.text.trim()),
332- child: const Text('Add'),
333- ),
334- ],
335- );
336- }
337-}
modified app/pubspec.lock +1 -1
@@ -510,5 +510,5 @@ packages:
510510 source: hosted
511511 version: "1.1.0"
512512 sdks:
513- dart: ">=3.13.4 <4.0.0"
513+ dart: ">=3.13.0 <4.0.0"
514514 flutter: ">=3.44.0"
@@ -510,5 +510,5 @@ packages:
510 source: hosted510 source: hosted
511 version: "1.1.0"511 version: "1.1.0"
512 sdks:512 sdks:
513- dart: ">=3.13.4 <4.0.0"513+ dart: ">=3.13.0 <4.0.0"
514 flutter: ">=3.44.0"514 flutter: ">=3.44.0"
modified app/pubspec.yaml +3 -1
@@ -19,7 +19,9 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
1919 version: 1.0.0+1
2020
2121 environment:
22- sdk: ^3.13.4
22+ # The app does not depend on a Dart 3.13 patch-only API. Accept the
23+ # available Flutter SDK's 3.13.0 while keeping the Dart 3.x upper bound.
24+ sdk: '>=3.13.0 <4.0.0'
2325
2426 # Dependencies specify other packages that your package needs in order to work.
2527 # To automatically upgrade your package dependencies to the latest versions
@@ -19,7 +19,9 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
19 version: 1.0.0+119 version: 1.0.0+1
20 20
21 environment:21 environment:
22- sdk: ^3.13.422+ # The app does not depend on a Dart 3.13 patch-only API. Accept the
23+ # available Flutter SDK's 3.13.0 while keeping the Dart 3.x upper bound.
24+ sdk: '>=3.13.0 <4.0.0'
23 25
24 # Dependencies specify other packages that your package needs in order to work.26 # Dependencies specify other packages that your package needs in order to work.
25 # To automatically upgrade your package dependencies to the latest versions27 # To automatically upgrade your package dependencies to the latest versions
modified app/test/widget_test.dart +26 -0
@@ -123,6 +123,32 @@ void main() {
123123 expect(find.textContaining('4 new'), findsOneWidget);
124124 });
125125
126+ testWidgets('feed management exposes add and removal controls',
127+ (tester) async {
128+ await pump(tester);
129+
130+ await tester.tap(find.byTooltip('Manage feeds').first);
131+ await tester.pumpAndSettle();
132+
133+ expect(find.text('Manage feeds'), findsOneWidget);
134+ expect(find.text('Add a subscription'), findsOneWidget);
135+ expect(find.text('Subscriptions'), findsOneWidget);
136+ expect(find.byTooltip('Remove Feed A'), findsOneWidget);
137+ expect(find.text('Backup & restore'), findsOneWidget);
138+ });
139+
140+ testWidgets('returning from feed management restores the reader',
141+ (tester) async {
142+ await pump(tester);
143+
144+ await tester.tap(find.byTooltip('Manage feeds').first);
145+ await tester.pumpAndSettle();
146+ await tester.tap(find.byTooltip('Back'));
147+ await tester.pumpAndSettle();
148+
149+ expect(find.text('First article'), findsOneWidget);
150+ });
151+
126152 testWidgets('marking an article read updates the count without a refetch',
127153 (tester) async {
128154 final state = await pump(tester);
@@ -123,6 +123,32 @@ void main() {
123 expect(find.textContaining('4 new'), findsOneWidget);123 expect(find.textContaining('4 new'), findsOneWidget);
124 });124 });
125 125
126+ testWidgets('feed management exposes add and removal controls',
127+ (tester) async {
128+ await pump(tester);
129+
130+ await tester.tap(find.byTooltip('Manage feeds').first);
131+ await tester.pumpAndSettle();
132+
133+ expect(find.text('Manage feeds'), findsOneWidget);
134+ expect(find.text('Add a subscription'), findsOneWidget);
135+ expect(find.text('Subscriptions'), findsOneWidget);
136+ expect(find.byTooltip('Remove Feed A'), findsOneWidget);
137+ expect(find.text('Backup & restore'), findsOneWidget);
138+ });
139+
140+ testWidgets('returning from feed management restores the reader',
141+ (tester) async {
142+ await pump(tester);
143+
144+ await tester.tap(find.byTooltip('Manage feeds').first);
145+ await tester.pumpAndSettle();
146+ await tester.tap(find.byTooltip('Back'));
147+ await tester.pumpAndSettle();
148+
149+ expect(find.text('First article'), findsOneWidget);
150+ });
151+
126 testWidgets('marking an article read updates the count without a refetch',152 testWidgets('marking an article read updates the count without a refetch',
127 (tester) async {153 (tester) async {
128 final state = await pump(tester);154 final state = await pump(tester);
modified deploy/modal_app.py +40 -1
@@ -31,6 +31,7 @@ REPO = pathlib.Path(__file__).parent.parent
3131
3232 APP_NAME = "glean-reader"
3333 PORT = 8080
34+PUBLIC_URL = "https://codegod100--glean-reader-serve.modal.run"
3435
3536 VOLUME_PATH = "/data"
3637 SNAPSHOT_DIR = f"{VOLUME_PATH}/db"
@@ -45,7 +46,7 @@ SNAPSHOT_INTERVAL = 300
4546
4647 image = (
4748 modal.Image.debian_slim(python_version="3.12")
48- .apt_install("curl", "xz-utils", "gcc", "libsqlite3-dev", "ca-certificates")
49+ .apt_install("curl", "xz-utils", "gcc", "sqlite3", "libsqlite3-dev", "ca-certificates")
4950 .run_commands(
5051 # choosenim is the supported installer and pins a known version;
5152 # Debian's Nim is far behind what this code needs.
@@ -75,6 +76,44 @@ secret = modal.Secret.from_name("glean-reader")
7576 app = modal.App(APP_NAME)
7677
7778
79+@app.function(image=image, secrets=[secret], timeout=300)
80+def import_opml(opml: str) -> str:
81+ """Import a backup through the live API without revealing GLEAN_TOKEN.
82+
83+ Run with ``modal run deploy/modal_app.py::import_opml --opml "$(<backup.opml)"``.
84+ Keeping this as a Modal function means only workspace members can invoke
85+ it, while the browser-facing reader remains protected by its shared token.
86+ """
87+ from urllib.parse import urlencode
88+ from urllib.request import Request, urlopen
89+
90+ token = os.environ["GLEAN_TOKEN"]
91+ request = Request(
92+ f"{PUBLIC_URL}/opml",
93+ data=urlencode({"opml": opml}).encode(),
94+ headers={"X-Glean-Token": token},
95+ method="POST",
96+ )
97+ with urlopen(request, timeout=240) as response:
98+ return response.read().decode()
99+
100+
101+@app.function(image=image, secrets=[secret], timeout=60)
102+def inspect_feeds() -> str:
103+ """Return live feed and unread-article data for a recovery check."""
104+ from urllib.request import Request, urlopen
105+
106+ headers = {"X-Glean-Token": os.environ["GLEAN_TOKEN"]}
107+ results = {}
108+ for path in ("/feeds", "/articles?status=unread&limit=5"):
109+ with urlopen(Request(f"{PUBLIC_URL}{path}", headers=headers), timeout=50) as response:
110+ results[path] = response.read().decode()
111+ import json
112+ body = json.dumps(results)
113+ print(body, flush=True)
114+ return body
115+
116+
78117 def _snapshot_once() -> None:
79118 """Copy each live database to the Volume, via a local staging file.
80119
@@ -31,6 +31,7 @@ REPO = pathlib.Path(__file__).parent.parent
31 31
32 APP_NAME = "glean-reader"32 APP_NAME = "glean-reader"
33 PORT = 808033 PORT = 8080
34+PUBLIC_URL = "https://codegod100--glean-reader-serve.modal.run"
34 35
35 VOLUME_PATH = "/data"36 VOLUME_PATH = "/data"
36 SNAPSHOT_DIR = f"{VOLUME_PATH}/db"37 SNAPSHOT_DIR = f"{VOLUME_PATH}/db"
@@ -45,7 +46,7 @@ SNAPSHOT_INTERVAL = 300
45 46
46 image = (47 image = (
47 modal.Image.debian_slim(python_version="3.12")48 modal.Image.debian_slim(python_version="3.12")
48- .apt_install("curl", "xz-utils", "gcc", "libsqlite3-dev", "ca-certificates")49+ .apt_install("curl", "xz-utils", "gcc", "sqlite3", "libsqlite3-dev", "ca-certificates")
49 .run_commands(50 .run_commands(
50 # choosenim is the supported installer and pins a known version;51 # choosenim is the supported installer and pins a known version;
51 # Debian's Nim is far behind what this code needs.52 # Debian's Nim is far behind what this code needs.
@@ -75,6 +76,44 @@ secret = modal.Secret.from_name("glean-reader")
75 app = modal.App(APP_NAME)76 app = modal.App(APP_NAME)
76 77
77 78
79+@app.function(image=image, secrets=[secret], timeout=300)
80+def import_opml(opml: str) -> str:
81+ """Import a backup through the live API without revealing GLEAN_TOKEN.
82+
83+ Run with ``modal run deploy/modal_app.py::import_opml --opml "$(<backup.opml)"``.
84+ Keeping this as a Modal function means only workspace members can invoke
85+ it, while the browser-facing reader remains protected by its shared token.
86+ """
87+ from urllib.parse import urlencode
88+ from urllib.request import Request, urlopen
89+
90+ token = os.environ["GLEAN_TOKEN"]
91+ request = Request(
92+ f"{PUBLIC_URL}/opml",
93+ data=urlencode({"opml": opml}).encode(),
94+ headers={"X-Glean-Token": token},
95+ method="POST",
96+ )
97+ with urlopen(request, timeout=240) as response:
98+ return response.read().decode()
99+
100+
101+@app.function(image=image, secrets=[secret], timeout=60)
102+def inspect_feeds() -> str:
103+ """Return live feed and unread-article data for a recovery check."""
104+ from urllib.request import Request, urlopen
105+
106+ headers = {"X-Glean-Token": os.environ["GLEAN_TOKEN"]}
107+ results = {}
108+ for path in ("/feeds", "/articles?status=unread&limit=5"):
109+ with urlopen(Request(f"{PUBLIC_URL}{path}", headers=headers), timeout=50) as response:
110+ results[path] = response.read().decode()
111+ import json
112+ body = json.dumps(results)
113+ print(body, flush=True)
114+ return body
115+
116+
78 def _snapshot_once() -> None:117 def _snapshot_once() -> None:
79 """Copy each live database to the Volume, via a local staging file.118 """Copy each live database to the Volume, via a local staging file.
80 119
added nandi-glean-subscriptions.opml +29 -0
new file mode 100644
@@ -0,0 +1,29 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<opml version="2.0">
3+ <head><title>Glean subscriptions — nandi.uk</title></head>
4+ <body>
5+ <outline text="(iterate think thoughts)" title="(iterate think thoughts)" type="rss" xmlUrl="https://yogthos.net/feed.xml"/>
6+ <outline text="This Week in GNOME" title="This Week in GNOME" type="rss" xmlUrl="https://thisweek.gnome.org/index.xml"/>
7+ <outline text="The Tangled Blog" title="The Tangled Blog" type="rss" xmlUrl="https://blog.tangled.org/feed.xml"/>
8+ <outline text="Daring Fireball" title="Daring Fireball" type="rss" xmlUrl="https://daringfireball.net/feeds/main"/>
9+ <outline text="404 Media" title="404 Media" type="rss" xmlUrl="https://www.404media.co/rss/"/>
10+ <outline text="Phoronix" title="Phoronix" type="rss" xmlUrl="https://www.phoronix.com/rss.php"/>
11+ <outline text="The Phoenix Architecture" title="The Phoenix Architecture" type="rss" xmlUrl="https://aicoding.leaflet.pub/rss"/>
12+ <outline text="articles" title="articles">
13+ <outline text="Hacker News" title="Hacker News" type="rss" xmlUrl="https://hackernews.com/rss"/>
14+ <outline text="HEY" title="HEY" type="rss" xmlUrl="https://www.hey.com/feed/new.atom"/>
15+ <outline text="Articles by Aaron Parecki" title="Articles by Aaron Parecki" type="rss" xmlUrl="https://aaronparecki.com/feed.xml"/>
16+ <outline text="The XMPP Blog on XMPP" title="The XMPP Blog on XMPP" type="rss" xmlUrl="https://mov.im/feed/pubsub.movim.eu/XMPP"/>
17+ <outline text="Gleam weekly" title="Gleam weekly" type="rss" xmlUrl="https://gleamweekly.com/atom.xml"/>
18+ <outline text="freeq blog" title="freeq blog" type="rss" xmlUrl="https://freeq.at/blog/feed.xml"/>
19+ <outline text="Lobsters" title="Lobsters" type="rss" xmlUrl="https://lobste.rs/rss"/>
20+ <outline text="TechCrunch" title="TechCrunch" type="rss" xmlUrl="https://techcrunch.com/feed/"/>
21+ <outline text="Matrix.org" title="Matrix.org" type="rss" xmlUrl="https://matrix.org/atom.xml"/>
22+ </outline>
23+ <outline text="tangled" title="tangled">
24+ <outline text="@mitchellh.com's timeline" title="@mitchellh.com's timeline" type="rss" xmlUrl="https://tangled.org/mitchellh.com/feed.atom"/>
25+ <outline text="@expede.wtf's timeline" title="@expede.wtf's timeline" type="rss" xmlUrl="https://tangled.org/expede.wtf/feed.atom"/>
26+ <outline text="activity feed for @aly.codes/tg" title="activity feed for @aly.codes/tg" type="rss" xmlUrl="https://tangled.org/aly.codes/tg/feed.atom"/>
27+ </outline>
28+ </body>
29+</opml>
new file mode 100644
@@ -0,0 +1,29 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<opml version="2.0">
3+ <head><title>Glean subscriptions — nandi.uk</title></head>
4+ <body>
5+ <outline text="(iterate think thoughts)" title="(iterate think thoughts)" type="rss" xmlUrl="https://yogthos.net/feed.xml"/>
6+ <outline text="This Week in GNOME" title="This Week in GNOME" type="rss" xmlUrl="https://thisweek.gnome.org/index.xml"/>
7+ <outline text="The Tangled Blog" title="The Tangled Blog" type="rss" xmlUrl="https://blog.tangled.org/feed.xml"/>
8+ <outline text="Daring Fireball" title="Daring Fireball" type="rss" xmlUrl="https://daringfireball.net/feeds/main"/>
9+ <outline text="404 Media" title="404 Media" type="rss" xmlUrl="https://www.404media.co/rss/"/>
10+ <outline text="Phoronix" title="Phoronix" type="rss" xmlUrl="https://www.phoronix.com/rss.php"/>
11+ <outline text="The Phoenix Architecture" title="The Phoenix Architecture" type="rss" xmlUrl="https://aicoding.leaflet.pub/rss"/>
12+ <outline text="articles" title="articles">
13+ <outline text="Hacker News" title="Hacker News" type="rss" xmlUrl="https://hackernews.com/rss"/>
14+ <outline text="HEY" title="HEY" type="rss" xmlUrl="https://www.hey.com/feed/new.atom"/>
15+ <outline text="Articles by Aaron Parecki" title="Articles by Aaron Parecki" type="rss" xmlUrl="https://aaronparecki.com/feed.xml"/>
16+ <outline text="The XMPP Blog on XMPP" title="The XMPP Blog on XMPP" type="rss" xmlUrl="https://mov.im/feed/pubsub.movim.eu/XMPP"/>
17+ <outline text="Gleam weekly" title="Gleam weekly" type="rss" xmlUrl="https://gleamweekly.com/atom.xml"/>
18+ <outline text="freeq blog" title="freeq blog" type="rss" xmlUrl="https://freeq.at/blog/feed.xml"/>
19+ <outline text="Lobsters" title="Lobsters" type="rss" xmlUrl="https://lobste.rs/rss"/>
20+ <outline text="TechCrunch" title="TechCrunch" type="rss" xmlUrl="https://techcrunch.com/feed/"/>
21+ <outline text="Matrix.org" title="Matrix.org" type="rss" xmlUrl="https://matrix.org/atom.xml"/>
22+ </outline>
23+ <outline text="tangled" title="tangled">
24+ <outline text="@mitchellh.com's timeline" title="@mitchellh.com's timeline" type="rss" xmlUrl="https://tangled.org/mitchellh.com/feed.atom"/>
25+ <outline text="@expede.wtf's timeline" title="@expede.wtf's timeline" type="rss" xmlUrl="https://tangled.org/expede.wtf/feed.atom"/>
26+ <outline text="activity feed for @aly.codes/tg" title="activity feed for @aly.codes/tg" type="rss" xmlUrl="https://tangled.org/aly.codes/tg/feed.atom"/>
27+ </outline>
28+ </body>
29+</opml>
modified spike/nim/glean.nim +17 -19
@@ -13,8 +13,8 @@
1313 ## as someone else's feed list. Unset, there is no gate at all and the local
1414 ## case stays exactly as simple as it was.
1515 ##
16-## GLEAN_WEB points at a directory of static files (a Flutter web build) to
17-## serve instead of the built-in page.
16+## GLEAN_WEB must point at the Flutter web build. The reader refuses to start
17+## without it so deployments cannot silently serve a different client.
1818 ##
1919 ## The pieces underneath -- fetching, parsing, storage, search, full-text
2020 ## extraction -- are the same modules the larger port built and tested.
@@ -25,10 +25,6 @@ import reader/[articlestore, feedfetcher, feedparser, feedstore, gleandb,
2525 opml, scraper, sqlite]
2626
2727 const
28- ## Baked in at compile time rather than read from disk, so the reader stays
29- ## one file to copy around.
30- IndexHtml = staticRead("ui.html")
31-
3228 ## Everything is stored per-user underneath because the schema came from a
3329 ## multi-user server. One constant user keeps that plumbing satisfied
3430 ## without inventing accounts.
@@ -38,7 +34,7 @@ const
3834 type Reader = ref object
3935 db: GleanDb
4036 token: string ## empty disables the gate entirely
41- webRoot: string ## empty serves the built-in page
37+ webRoot: string ## required Flutter web bundle
4238
4339 proc cookieValue(header, name: string): string =
4440 for part in header.split(';'):
@@ -107,16 +103,18 @@ proc serveStatic(r: Reader, req: Request, rel: string): Future[bool] {.async.} =
107103 let full = absolutePath(r.webRoot / rel)
108104 let root = absolutePath(r.webRoot)
109105 if not full.startsWith(root) or not fileExists(full): return false
110- await req.respond(Http200, readFile(full),
111- newHttpHeaders({"Content-Type": contentTypeFor(full)}))
106+ var headers = newHttpHeaders({"Content-Type": contentTypeFor(full)})
107+ # Flutter's bootstrap and main bundle keep stable filenames. Revalidate
108+ # them so a Modal deploy cannot leave browsers running yesterday's client.
109+ let ext = full.splitFile.ext.toLowerAscii
110+ if ext in [".html", ".js", ".mjs"]:
111+ headers["Cache-Control"] = "no-cache"
112+ await req.respond(Http200, readFile(full), headers)
112113 true
113114
114115 proc serveApp(r: Reader, req: Request) {.async.} =
115- ## The client: a Flutter web build when one is configured, otherwise the
116- ## page baked into the binary.
117- if await serveStatic(r, req, "index.html"): return
118- await req.respond(Http200, IndexHtml,
119- newHttpHeaders({"Content-Type": "text/html; charset=utf-8"}))
116+ if not await serveStatic(r, req, "index.html"):
117+ await fail(req, Http500, "Flutter web bundle is unavailable")
120118
121119 proc toJson(a: Article): JsonNode =
122120 %*{
@@ -384,11 +382,11 @@ proc main() {.async.} =
384382 db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser))
385383
386384 let token = getEnv("GLEAN_TOKEN")
387- var webRoot = getEnv("GLEAN_WEB")
388- if webRoot.len > 0 and not dirExists(webRoot):
389- # Failing loudly beats silently serving the fallback page and leaving
390- # someone wondering why their client never updated.
391- quit(&"GLEAN_WEB points at {webRoot}, which is not a directory")
385+ let webRoot = getEnv("GLEAN_WEB")
386+ if webRoot.len == 0:
387+ quit("GLEAN_WEB is required and must point at a Flutter web build")
388+ if not dirExists(webRoot) or not fileExists(webRoot / "index.html"):
389+ quit(&"GLEAN_WEB points at {webRoot}, which has no index.html")
392390
393391 let reader = Reader(db: db, token: token, webRoot: webRoot)
394392
@@ -13,8 +13,8 @@
13 ## as someone else's feed list. Unset, there is no gate at all and the local13 ## as someone else's feed list. Unset, there is no gate at all and the local
14 ## case stays exactly as simple as it was.14 ## case stays exactly as simple as it was.
15 ##15 ##
16-## GLEAN_WEB points at a directory of static files (a Flutter web build) to16+## GLEAN_WEB must point at the Flutter web build. The reader refuses to start
17-## serve instead of the built-in page.17+## without it so deployments cannot silently serve a different client.
18 ##18 ##
19 ## The pieces underneath -- fetching, parsing, storage, search, full-text19 ## The pieces underneath -- fetching, parsing, storage, search, full-text
20 ## extraction -- are the same modules the larger port built and tested.20 ## extraction -- are the same modules the larger port built and tested.
@@ -25,10 +25,6 @@ import reader/[articlestore, feedfetcher, feedparser, feedstore, gleandb,
25 opml, 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 stays
29- ## one file to copy around.
30- IndexHtml = staticRead("ui.html")
31-
32 ## Everything is stored per-user underneath because the schema came from a28 ## Everything is stored per-user underneath because the schema came from a
33 ## multi-user server. One constant user keeps that plumbing satisfied29 ## multi-user server. One constant user keeps that plumbing satisfied
34 ## without inventing accounts.30 ## without inventing accounts.
@@ -38,7 +34,7 @@ const
38 type Reader = ref object34 type Reader = ref object
39 db: GleanDb35 db: GleanDb
40 token: string ## empty disables the gate entirely36 token: string ## empty disables the gate entirely
41- webRoot: string ## empty serves the built-in page37+ webRoot: string ## required Flutter web bundle
42 38
43 proc cookieValue(header, name: string): string =39 proc cookieValue(header, name: string): string =
44 for part in header.split(';'):40 for part in header.split(';'):
@@ -107,16 +103,18 @@ proc serveStatic(r: Reader, req: Request, rel: string): Future[bool] {.async.} =
107 let full = absolutePath(r.webRoot / rel)103 let full = absolutePath(r.webRoot / rel)
108 let root = absolutePath(r.webRoot)104 let root = absolutePath(r.webRoot)
109 if not full.startsWith(root) or not fileExists(full): return false105 if not full.startsWith(root) or not fileExists(full): return false
110- await req.respond(Http200, readFile(full),106+ var headers = newHttpHeaders({"Content-Type": contentTypeFor(full)})
111- newHttpHeaders({"Content-Type": contentTypeFor(full)}))107+ # Flutter's bootstrap and main bundle keep stable filenames. Revalidate
108+ # them so a Modal deploy cannot leave browsers running yesterday's client.
109+ let ext = full.splitFile.ext.toLowerAscii
110+ if ext in [".html", ".js", ".mjs"]:
111+ headers["Cache-Control"] = "no-cache"
112+ await req.respond(Http200, readFile(full), headers)
112 true113 true
113 114
114 proc serveApp(r: Reader, req: Request) {.async.} =115 proc serveApp(r: Reader, req: Request) {.async.} =
115- ## The client: a Flutter web build when one is configured, otherwise the116+ if not await serveStatic(r, req, "index.html"):
116- ## page baked into the binary.117+ await fail(req, Http500, "Flutter web bundle is unavailable")
117- if await serveStatic(r, req, "index.html"): return
118- await req.respond(Http200, IndexHtml,
119- newHttpHeaders({"Content-Type": "text/html; charset=utf-8"}))
120 118
121 proc toJson(a: Article): JsonNode =119 proc toJson(a: Article): JsonNode =
122 %*{120 %*{
@@ -384,11 +382,11 @@ proc main() {.async.} =
384 db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser))382 db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser))
385 383
386 let token = getEnv("GLEAN_TOKEN")384 let token = getEnv("GLEAN_TOKEN")
387- var webRoot = getEnv("GLEAN_WEB")385+ let webRoot = getEnv("GLEAN_WEB")
388- if webRoot.len > 0 and not dirExists(webRoot):386+ if webRoot.len == 0:
389- # Failing loudly beats silently serving the fallback page and leaving387+ quit("GLEAN_WEB is required and must point at a Flutter web build")
390- # someone wondering why their client never updated.388+ if not dirExists(webRoot) or not fileExists(webRoot / "index.html"):
391- quit(&"GLEAN_WEB points at {webRoot}, which is not a directory")389+ quit(&"GLEAN_WEB points at {webRoot}, which has no index.html")
392 390
393 let reader = Reader(db: db, token: token, webRoot: webRoot)391 let reader = Reader(db: db, token: token, webRoot: webRoot)
394 392
deleted spike/nim/ui.html +0 -299
deleted file mode 100644
@@ -1,299 +0,0 @@
1-<!doctype html>
2-<meta charset="utf-8">
3-<meta name="viewport" content="width=device-width, initial-scale=1">
4-<title>glean</title>
5-<style>
6- :root {
7- --bg: #fafaf7; --fg: #1a1a1a; --muted: #6b6b6b; --line: #e0e0da;
8- --surface: #fff; --accent: #00754a; --danger: #c82014;
9- }
10- @media (prefers-color-scheme: dark) {
11- :root {
12- --bg: #16161a; --fg: #e8e8e3; --muted: #9a9a9a; --line: #2c2c32;
13- --surface: #1d1d22; --accent: #4ade80; --danger: #ff6b5e;
14- }
15- }
16- * { box-sizing: border-box; }
17- body {
18- margin: 0; background: var(--bg); color: var(--fg);
19- font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
20- display: grid; grid-template-columns: 280px 1fr; height: 100vh;
21- }
22- /* The sidebar scrolls independently of the article, so a long read never
23- loses the feed list. */
24- aside {
25- border-right: 1px solid var(--line); overflow-y: auto; padding: 14px;
26- background: var(--surface);
27- }
28- main { overflow-y: auto; padding: 20px 28px; }
29- h1 { font-size: 18px; margin: 0 0 14px; letter-spacing: -.01em; }
30- h1 span { color: var(--muted); font-weight: 400; font-size: 13px; }
31- button, input, select {
32- font: inherit; color: inherit; background: var(--bg);
33- border: 1px solid var(--line); border-radius: 6px; padding: 6px 9px;
34- }
35- button { cursor: pointer; }
36- button:hover { border-color: var(--accent); }
37- button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
38- .row { display: flex; gap: 6px; margin-bottom: 10px; }
39- .row input { flex: 1; min-width: 0; }
40- .feed {
41- display: flex; align-items: center; gap: 8px; padding: 6px 8px;
42- border-radius: 6px; cursor: pointer; font-size: 14px;
43- }
44- .feed:hover { background: var(--bg); }
45- .feed.on { background: var(--bg); font-weight: 600; }
46- .feed .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
47- .count { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
48- .feed .x { color: var(--muted); opacity: 0; }
49- .feed:hover .x { opacity: 1; }
50- .feed .x:hover { color: var(--danger); }
51- .item {
52- padding: 12px 0; border-bottom: 1px solid var(--line); cursor: pointer;
53- }
54- .item.read .title { color: var(--muted); font-weight: 400; }
55- .title { font-weight: 600; margin-bottom: 3px; }
56- .meta { color: var(--muted); font-size: 12.5px; }
57- .summary { color: var(--muted); font-size: 13.5px; margin-top: 4px;
58- display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
59- overflow: hidden; }
60- article { max-width: 42em; }
61- article h2 { font-size: 24px; line-height: 1.25; margin: 0 0 6px; }
62- .body { margin-top: 18px; }
63- .body img { max-width: 100%; height: auto; }
64- .body pre { overflow-x: auto; background: var(--surface); padding: 10px;
65- border-radius: 6px; }
66- .body a { color: var(--accent); }
67- .body blockquote { border-left: 3px solid var(--line); margin-left: 0;
68- padding-left: 14px; color: var(--muted); }
69- .empty { color: var(--muted); padding: 30px 0; }
70- .err { color: var(--danger); font-size: 13px; }
71- .bar { display: flex; gap: 8px; align-items: center; margin-bottom: 14px;
72- flex-wrap: wrap; }
73- .bar input[type=search] { flex: 1; min-width: 140px; }
74- @media (max-width: 720px) {
75- body { grid-template-columns: 1fr; }
76- aside { display: none; }
77- aside.show { display: block; }
78- }
79-</style>
80-
81-<aside>
82- <h1>glean <span id="total"></span></h1>
83- <div class="row">
84- <input id="url" placeholder="feed or site URL" autocomplete="off">
85- <button id="add" class="primary">+</button>
86- </div>
87- <div class="row">
88- <button id="refresh">Refresh</button>
89- <button id="allread">Mark read</button>
90- </div>
91- <div id="err" class="err"></div>
92- <div id="feeds"></div>
93-</aside>
94-
95-<main>
96- <div class="bar">
97- <select id="status">
98- <option value="unread">Unread</option>
99- <option value="all">All</option>
100- <option value="read">Read</option>
101- </select>
102- <input id="q" type="search" placeholder="Search…">
103- <button id="back" hidden>← Back</button>
104- </div>
105- <div id="view"></div>
106-</main>
107-
108-<script>
109-const $ = s => document.querySelector(s);
110-const state = { feed: "", status: "unread", q: "", article: null };
111-
112-const api = async (path, opts) => {
113- const res = await fetch(path, opts);
114- const body = await res.json().catch(() => ({ error: "bad response" }));
115- if (!res.ok || body.error) throw new Error(body.error || `HTTP ${res.status}`);
116- return body;
117-};
118-const post = (path, params) =>
119- api(path, {
120- method: "POST",
121- headers: { "Content-Type": "application/x-www-form-urlencoded" },
122- body: new URLSearchParams(params || {}),
123- });
124-
125-const say = msg => { $("#err").textContent = msg || ""; };
126-const when = iso => {
127- if (!iso) return "";
128- const d = new Date(iso), mins = (Date.now() - d) / 60000;
129- if (mins < 60) return `${Math.max(1, Math.round(mins))}m`;
130- if (mins < 1440) return `${Math.round(mins / 60)}h`;
131- if (mins < 43200) return `${Math.round(mins / 1440)}d`;
132- return d.toLocaleDateString();
133-};
134-
135-async function loadFeeds() {
136- const feeds = await api("/feeds");
137- const { count } = await api("/unread");
138- $("#total").textContent = count ? `${count} unread` : "all read";
139-
140- const el = $("#feeds");
141- el.innerHTML = "";
142- el.append(feedRow({ feed_url: "", title: "All feeds", unread: count }));
143- for (const f of feeds) el.append(feedRow(f));
144-}
145-
146-function feedRow(f) {
147- const div = document.createElement("div");
148- div.className = "feed" + (state.feed === f.feed_url ? " on" : "");
149- div.innerHTML =
150- `<span class="name"></span><span class="count"></span>` +
151- (f.feed_url ? `<span class="x" title="Unsubscribe">×</span>` : "");
152- // textContent, not innerHTML: a feed title is whatever a stranger's server
153- // put in <title>.
154- div.querySelector(".name").textContent = f.title || f.feed_url;
155- div.querySelector(".count").textContent = f.unread || "";
156- div.onclick = e => {
157- if (e.target.classList.contains("x")) return;
158- state.feed = f.feed_url; state.article = null; render();
159- };
160- const x = div.querySelector(".x");
161- if (x) x.onclick = async e => {
162- e.stopPropagation();
163- if (!confirm(`Unsubscribe from ${f.title || f.feed_url}?`)) return;
164- await api(`/feeds?url=${encodeURIComponent(f.feed_url)}`, { method: "DELETE" });
165- if (state.feed === f.feed_url) state.feed = "";
166- render();
167- };
168- return div;
169-}
170-
171-async function loadArticles() {
172- const p = new URLSearchParams({ limit: "100" });
173- if (state.q) p.set("q", state.q);
174- else {
175- p.set("status", state.status);
176- if (state.feed) p.set("feed", state.feed);
177- }
178- return api(`/articles?${p}`);
179-}
180-
181-function listView(items) {
182- const view = $("#view");
183- view.innerHTML = "";
184- if (!items.length) {
185- view.innerHTML = `<div class="empty">Nothing here.</div>`;
186- return;
187- }
188- for (const a of items) {
189- const div = document.createElement("div");
190- div.className = "item" + (a.is_read ? " read" : "");
191- div.innerHTML = `<div class="title"></div><div class="meta"></div>` +
192- (a.summary ? `<div class="summary"></div>` : "");
193- div.querySelector(".title").textContent = a.title || "(untitled)";
194- div.querySelector(".meta").textContent =
195- [a.feed_title, when(a.published), a.author].filter(Boolean).join(" · ");
196- if (a.summary) {
197- // Summaries are HTML in most feeds; show them as the text they read as.
198- const tmp = document.createElement("div");
199- tmp.innerHTML = a.summary;
200- div.querySelector(".summary").textContent = tmp.textContent;
201- }
202- div.onclick = () => openArticle(a.id);
203- view.append(div);
204- }
205-}
206-
207-async function openArticle(id) {
208- const a = await api(`/articles/${id}`);
209- state.article = a;
210- if (!a.is_read) { await post("/read", { id }); loadFeeds(); }
211-
212- const view = $("#view");
213- view.innerHTML = `<article>
214- <h2></h2><div class="meta"></div>
215- <div class="bar" style="margin-top:14px">
216- <a id="orig" target="_blank" rel="noopener noreferrer"><button>Open original</button></a>
217- <button id="full">Fetch full text</button>
218- <button id="unread">Mark unread</button>
219- </div>
220- <div class="body"></div>
221- </article>`;
222- view.querySelector("h2").textContent = a.title || "(untitled)";
223- view.querySelector(".meta").textContent =
224- [a.feed_title, when(a.published), a.author].filter(Boolean).join(" · ");
225- // The server sanitises every article body through the same whitelist the
226- // scraper uses, so this is the one place innerHTML is deliberate.
227- view.querySelector(".body").innerHTML = a.content || "<p><em>No content.</em></p>";
228- $("#orig").href = a.url || "#";
229- $("#back").hidden = false;
230-
231- $("#full").onclick = async e => {
232- e.target.disabled = true; e.target.textContent = "Fetching…";
233- try {
234- const r = await post("/fetch-content", { id });
235- view.querySelector(".body").innerHTML = r.content;
236- e.target.textContent = "Fetched";
237- } catch (err) {
238- e.target.textContent = "Fetch full text";
239- e.target.disabled = false;
240- say(err.message);
241- }
242- };
243- $("#unread").onclick = async () => {
244- await post("/read", { id, undo: "1" });
245- state.article = null; render();
246- };
247-}
248-
249-async function render() {
250- say("");
251- try {
252- await loadFeeds();
253- if (state.article) return;
254- $("#back").hidden = true;
255- listView(await loadArticles());
256- } catch (e) { say(e.message); }
257-}
258-
259-$("#add").onclick = async () => {
260- const url = $("#url").value.trim();
261- if (!url) return;
262- $("#add").disabled = true;
263- try {
264- const r = await post("/feeds", { url });
265- if (r.error) say(r.error); else $("#url").value = "";
266- render();
267- } catch (e) { say(e.message); }
268- finally { $("#add").disabled = false; }
269-};
270-$("#url").onkeydown = e => { if (e.key === "Enter") $("#add").click(); };
271-
272-$("#refresh").onclick = async e => {
273- e.target.disabled = true; e.target.textContent = "Refreshing…";
274- try {
275- const r = await post("/refresh");
276- if (r.errors?.length) say(`${r.errors.length} feed(s) failed`);
277- state.article = null; render();
278- } catch (err) { say(err.message); }
279- finally { e.target.disabled = false; e.target.textContent = "Refresh"; }
280-};
281-
282-$("#allread").onclick = async () => {
283- if (!confirm(state.feed ? "Mark this feed read?" : "Mark everything read?")) return;
284- await post("/read-all", state.feed ? { feed: state.feed } : {});
285- state.article = null; render();
286-};
287-
288-$("#status").onchange = e => { state.status = e.target.value; state.article = null; render(); };
289-$("#back").onclick = () => { state.article = null; render(); };
290-
291-let timer;
292-$("#q").oninput = e => {
293- clearTimeout(timer);
294- // Debounced: every keystroke is an FTS5 query otherwise.
295- timer = setTimeout(() => { state.q = e.target.value.trim(); state.article = null; render(); }, 250);
296-};
297-
298-render();
299-</script>
deleted file mode 100644
@@ -1,299 +0,0 @@
1-<!doctype html>
2-<meta charset="utf-8">
3-<meta name="viewport" content="width=device-width, initial-scale=1">
4-<title>glean</title>
5-<style>
6- :root {
7- --bg: #fafaf7; --fg: #1a1a1a; --muted: #6b6b6b; --line: #e0e0da;
8- --surface: #fff; --accent: #00754a; --danger: #c82014;
9- }
10- @media (prefers-color-scheme: dark) {
11- :root {
12- --bg: #16161a; --fg: #e8e8e3; --muted: #9a9a9a; --line: #2c2c32;
13- --surface: #1d1d22; --accent: #4ade80; --danger: #ff6b5e;
14- }
15- }
16- * { box-sizing: border-box; }
17- body {
18- margin: 0; background: var(--bg); color: var(--fg);
19- font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
20- display: grid; grid-template-columns: 280px 1fr; height: 100vh;
21- }
22- /* The sidebar scrolls independently of the article, so a long read never
23- loses the feed list. */
24- aside {
25- border-right: 1px solid var(--line); overflow-y: auto; padding: 14px;
26- background: var(--surface);
27- }
28- main { overflow-y: auto; padding: 20px 28px; }
29- h1 { font-size: 18px; margin: 0 0 14px; letter-spacing: -.01em; }
30- h1 span { color: var(--muted); font-weight: 400; font-size: 13px; }
31- button, input, select {
32- font: inherit; color: inherit; background: var(--bg);
33- border: 1px solid var(--line); border-radius: 6px; padding: 6px 9px;
34- }
35- button { cursor: pointer; }
36- button:hover { border-color: var(--accent); }
37- button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
38- .row { display: flex; gap: 6px; margin-bottom: 10px; }
39- .row input { flex: 1; min-width: 0; }
40- .feed {
41- display: flex; align-items: center; gap: 8px; padding: 6px 8px;
42- border-radius: 6px; cursor: pointer; font-size: 14px;
43- }
44- .feed:hover { background: var(--bg); }
45- .feed.on { background: var(--bg); font-weight: 600; }
46- .feed .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
47- .count { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
48- .feed .x { color: var(--muted); opacity: 0; }
49- .feed:hover .x { opacity: 1; }
50- .feed .x:hover { color: var(--danger); }
51- .item {
52- padding: 12px 0; border-bottom: 1px solid var(--line); cursor: pointer;
53- }
54- .item.read .title { color: var(--muted); font-weight: 400; }
55- .title { font-weight: 600; margin-bottom: 3px; }
56- .meta { color: var(--muted); font-size: 12.5px; }
57- .summary { color: var(--muted); font-size: 13.5px; margin-top: 4px;
58- display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
59- overflow: hidden; }
60- article { max-width: 42em; }
61- article h2 { font-size: 24px; line-height: 1.25; margin: 0 0 6px; }
62- .body { margin-top: 18px; }
63- .body img { max-width: 100%; height: auto; }
64- .body pre { overflow-x: auto; background: var(--surface); padding: 10px;
65- border-radius: 6px; }
66- .body a { color: var(--accent); }
67- .body blockquote { border-left: 3px solid var(--line); margin-left: 0;
68- padding-left: 14px; color: var(--muted); }
69- .empty { color: var(--muted); padding: 30px 0; }
70- .err { color: var(--danger); font-size: 13px; }
71- .bar { display: flex; gap: 8px; align-items: center; margin-bottom: 14px;
72- flex-wrap: wrap; }
73- .bar input[type=search] { flex: 1; min-width: 140px; }
74- @media (max-width: 720px) {
75- body { grid-template-columns: 1fr; }
76- aside { display: none; }
77- aside.show { display: block; }
78- }
79-</style>
80-
81-<aside>
82- <h1>glean <span id="total"></span></h1>
83- <div class="row">
84- <input id="url" placeholder="feed or site URL" autocomplete="off">
85- <button id="add" class="primary">+</button>
86- </div>
87- <div class="row">
88- <button id="refresh">Refresh</button>
89- <button id="allread">Mark read</button>
90- </div>
91- <div id="err" class="err"></div>
92- <div id="feeds"></div>
93-</aside>
94-
95-<main>
96- <div class="bar">
97- <select id="status">
98- <option value="unread">Unread</option>
99- <option value="all">All</option>
100- <option value="read">Read</option>
101- </select>
102- <input id="q" type="search" placeholder="Search…">
103- <button id="back" hidden>← Back</button>
104- </div>
105- <div id="view"></div>
106-</main>
107-
108-<script>
109-const $ = s => document.querySelector(s);
110-const state = { feed: "", status: "unread", q: "", article: null };
111-
112-const api = async (path, opts) => {
113- const res = await fetch(path, opts);
114- const body = await res.json().catch(() => ({ error: "bad response" }));
115- if (!res.ok || body.error) throw new Error(body.error || `HTTP ${res.status}`);
116- return body;
117-};
118-const post = (path, params) =>
119- api(path, {
120- method: "POST",
121- headers: { "Content-Type": "application/x-www-form-urlencoded" },
122- body: new URLSearchParams(params || {}),
123- });
124-
125-const say = msg => { $("#err").textContent = msg || ""; };
126-const when = iso => {
127- if (!iso) return "";
128- const d = new Date(iso), mins = (Date.now() - d) / 60000;
129- if (mins < 60) return `${Math.max(1, Math.round(mins))}m`;
130- if (mins < 1440) return `${Math.round(mins / 60)}h`;
131- if (mins < 43200) return `${Math.round(mins / 1440)}d`;
132- return d.toLocaleDateString();
133-};
134-
135-async function loadFeeds() {
136- const feeds = await api("/feeds");
137- const { count } = await api("/unread");
138- $("#total").textContent = count ? `${count} unread` : "all read";
139-
140- const el = $("#feeds");
141- el.innerHTML = "";
142- el.append(feedRow({ feed_url: "", title: "All feeds", unread: count }));
143- for (const f of feeds) el.append(feedRow(f));
144-}
145-
146-function feedRow(f) {
147- const div = document.createElement("div");
148- div.className = "feed" + (state.feed === f.feed_url ? " on" : "");
149- div.innerHTML =
150- `<span class="name"></span><span class="count"></span>` +
151- (f.feed_url ? `<span class="x" title="Unsubscribe">×</span>` : "");
152- // textContent, not innerHTML: a feed title is whatever a stranger's server
153- // put in <title>.
154- div.querySelector(".name").textContent = f.title || f.feed_url;
155- div.querySelector(".count").textContent = f.unread || "";
156- div.onclick = e => {
157- if (e.target.classList.contains("x")) return;
158- state.feed = f.feed_url; state.article = null; render();
159- };
160- const x = div.querySelector(".x");
161- if (x) x.onclick = async e => {
162- e.stopPropagation();
163- if (!confirm(`Unsubscribe from ${f.title || f.feed_url}?`)) return;
164- await api(`/feeds?url=${encodeURIComponent(f.feed_url)}`, { method: "DELETE" });
165- if (state.feed === f.feed_url) state.feed = "";
166- render();
167- };
168- return div;
169-}
170-
171-async function loadArticles() {
172- const p = new URLSearchParams({ limit: "100" });
173- if (state.q) p.set("q", state.q);
174- else {
175- p.set("status", state.status);
176- if (state.feed) p.set("feed", state.feed);
177- }
178- return api(`/articles?${p}`);
179-}
180-
181-function listView(items) {
182- const view = $("#view");
183- view.innerHTML = "";
184- if (!items.length) {
185- view.innerHTML = `<div class="empty">Nothing here.</div>`;
186- return;
187- }
188- for (const a of items) {
189- const div = document.createElement("div");
190- div.className = "item" + (a.is_read ? " read" : "");
191- div.innerHTML = `<div class="title"></div><div class="meta"></div>` +
192- (a.summary ? `<div class="summary"></div>` : "");
193- div.querySelector(".title").textContent = a.title || "(untitled)";
194- div.querySelector(".meta").textContent =
195- [a.feed_title, when(a.published), a.author].filter(Boolean).join(" · ");
196- if (a.summary) {
197- // Summaries are HTML in most feeds; show them as the text they read as.
198- const tmp = document.createElement("div");
199- tmp.innerHTML = a.summary;
200- div.querySelector(".summary").textContent = tmp.textContent;
201- }
202- div.onclick = () => openArticle(a.id);
203- view.append(div);
204- }
205-}
206-
207-async function openArticle(id) {
208- const a = await api(`/articles/${id}`);
209- state.article = a;
210- if (!a.is_read) { await post("/read", { id }); loadFeeds(); }
211-
212- const view = $("#view");
213- view.innerHTML = `<article>
214- <h2></h2><div class="meta"></div>
215- <div class="bar" style="margin-top:14px">
216- <a id="orig" target="_blank" rel="noopener noreferrer"><button>Open original</button></a>
217- <button id="full">Fetch full text</button>
218- <button id="unread">Mark unread</button>
219- </div>
220- <div class="body"></div>
221- </article>`;
222- view.querySelector("h2").textContent = a.title || "(untitled)";
223- view.querySelector(".meta").textContent =
224- [a.feed_title, when(a.published), a.author].filter(Boolean).join(" · ");
225- // The server sanitises every article body through the same whitelist the
226- // scraper uses, so this is the one place innerHTML is deliberate.
227- view.querySelector(".body").innerHTML = a.content || "<p><em>No content.</em></p>";
228- $("#orig").href = a.url || "#";
229- $("#back").hidden = false;
230-
231- $("#full").onclick = async e => {
232- e.target.disabled = true; e.target.textContent = "Fetching…";
233- try {
234- const r = await post("/fetch-content", { id });
235- view.querySelector(".body").innerHTML = r.content;
236- e.target.textContent = "Fetched";
237- } catch (err) {
238- e.target.textContent = "Fetch full text";
239- e.target.disabled = false;
240- say(err.message);
241- }
242- };
243- $("#unread").onclick = async () => {
244- await post("/read", { id, undo: "1" });
245- state.article = null; render();
246- };
247-}
248-
249-async function render() {
250- say("");
251- try {
252- await loadFeeds();
253- if (state.article) return;
254- $("#back").hidden = true;
255- listView(await loadArticles());
256- } catch (e) { say(e.message); }
257-}
258-
259-$("#add").onclick = async () => {
260- const url = $("#url").value.trim();
261- if (!url) return;
262- $("#add").disabled = true;
263- try {
264- const r = await post("/feeds", { url });
265- if (r.error) say(r.error); else $("#url").value = "";
266- render();
267- } catch (e) { say(e.message); }
268- finally { $("#add").disabled = false; }
269-};
270-$("#url").onkeydown = e => { if (e.key === "Enter") $("#add").click(); };
271-
272-$("#refresh").onclick = async e => {
273- e.target.disabled = true; e.target.textContent = "Refreshing…";
274- try {
275- const r = await post("/refresh");
276- if (r.errors?.length) say(`${r.errors.length} feed(s) failed`);
277- state.article = null; render();
278- } catch (err) { say(err.message); }
279- finally { e.target.disabled = false; e.target.textContent = "Refresh"; }
280-};
281-
282-$("#allread").onclick = async () => {
283- if (!confirm(state.feed ? "Mark this feed read?" : "Mark everything read?")) return;
284- await post("/read-all", state.feed ? { feed: state.feed } : {});
285- state.article = null; render();
286-};
287-
288-$("#status").onchange = e => { state.status = e.target.value; state.article = null; render(); };
289-$("#back").onclick = () => { state.article = null; render(); };
290-
291-let timer;
292-$("#q").oninput = e => {
293- clearTimeout(timer);
294- // Debounced: every keystroke is an FTS5 query otherwise.
295- timer = setTimeout(() => { state.q = e.target.value.trim(); state.article = null; render(); }, 250);
296-};
297-
298-render();
299-</script>