oldnews 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {oldnews-0.2.0 → oldnews-0.4.0}/PKG-INFO +2 -2
  2. {oldnews-0.2.0 → oldnews-0.4.0}/pyproject.toml +2 -2
  3. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/__main__.py +67 -24
  4. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/commands/__init__.py +10 -2
  5. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/commands/main.py +30 -2
  6. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/__init__.py +2 -0
  7. oldnews-0.4.0/src/oldnews/data/reset.py +26 -0
  8. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/providers/main.py +10 -2
  9. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/screens/main.py +116 -107
  10. oldnews-0.4.0/src/oldnews/sync.py +158 -0
  11. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/widgets/article_content.py +17 -2
  12. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/widgets/navigation.py +14 -0
  13. {oldnews-0.2.0 → oldnews-0.4.0}/README.md +0 -0
  14. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/__init__.py +0 -0
  15. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/auth.py +0 -0
  16. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/config.py +0 -0
  17. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/db.py +0 -0
  18. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/last_grab.py +0 -0
  19. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/local_articles.py +0 -0
  20. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/local_folders.py +0 -0
  21. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/local_subscriptions.py +0 -0
  22. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/local_unread.py +0 -0
  23. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/locations.py +0 -0
  24. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/navigation_state.py +0 -0
  25. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/oldnews.py +0 -0
  26. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/providers/__init__.py +0 -0
  27. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/py.typed +0 -0
  28. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/screens/__init__.py +0 -0
  29. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/screens/login.py +0 -0
  30. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/widgets/__init__.py +0 -0
  31. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/widgets/_after_highlight.py +0 -0
  32. {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/widgets/article_list.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oldnews
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: A terminal-based client for TheOldReader
5
5
  Keywords: atom,client,Google Reader,RSS,TheOldReader,terminal,news-reader,news
6
6
  Author: Dave Pearson
7
7
  Author-email: Dave Pearson <davep@davep.org>
8
8
  License-Expression: GPL-3.0-or-later
9
- Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Development Status :: 4 - Beta
10
10
  Classifier: Operating System :: OS Independent
11
11
  Classifier: Programming Language :: Python :: 3 :: Only
12
12
  Classifier: Programming Language :: Python :: 3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oldnews"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "A terminal-based client for TheOldReader"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -27,7 +27,7 @@ dependencies = [
27
27
  "xdg-base-dirs>=6.0.2",
28
28
  ]
29
29
  classifiers = [
30
- "Development Status :: 3 - Alpha",
30
+ "Development Status :: 4 - Beta",
31
31
  "Operating System :: OS Independent",
32
32
  "Programming Language :: Python :: 3 :: Only",
33
33
  "Programming Language :: Python :: 3",
@@ -9,7 +9,7 @@ from operator import attrgetter
9
9
  ##############################################################################
10
10
  # Local imports.
11
11
  from . import __doc__, __version__
12
- from .data import initialise_database
12
+ from .data import initialise_database, reset_data
13
13
  from .oldnews import OldNews
14
14
 
15
15
 
@@ -37,27 +37,48 @@ def get_args() -> Namespace:
37
37
  version=f"%(prog)s v{__version__}",
38
38
  )
39
39
 
40
- # Add --license
40
+ # Add --theme
41
41
  parser.add_argument(
42
- "--license",
43
- "--licence",
42
+ "-t",
43
+ "--theme",
44
+ help="Set the theme for the application (see `themes` command for available themes)",
45
+ )
46
+
47
+ # Allow for commands on the command line.
48
+ sub_parser = parser.add_subparsers(
49
+ dest="command", help="Available commands", required=False
50
+ )
51
+
52
+ # Add the 'license' command.
53
+ sub_parser.add_parser(
54
+ "license",
55
+ aliases=["licence"],
44
56
  help="Show license information",
45
- action="store_true",
46
57
  )
47
58
 
48
- # Add --bindings
49
- parser.add_argument(
50
- "-b",
51
- "--bindings",
59
+ # Add the 'bindings' command.
60
+ sub_parser.add_parser(
61
+ "bindings",
52
62
  help="List commands that can have their bindings changed",
53
- action="store_true",
54
63
  )
55
64
 
56
- # Add --theme
57
- parser.add_argument(
58
- "-t",
59
- "--theme",
60
- help="Set the theme for the application (set to ? to list available themes)",
65
+ # Add the 'themes' command.
66
+ sub_parser.add_parser(
67
+ "themes", help="List the available themes that can be used with --theme"
68
+ )
69
+
70
+ # Add the 'reset' command.
71
+ reset = sub_parser.add_parser(
72
+ "reset", help="Remove all data downloaded from TheOldReader"
73
+ )
74
+ reset.add_argument(
75
+ "-l", "--logout", help="Force a logout from TheOldReader", action="store_true"
76
+ )
77
+ reset.add_argument(
78
+ "-y",
79
+ "--yes",
80
+ help="Perform the reset without confirming first",
81
+ action="store_true",
61
82
  )
62
83
 
63
84
  # Finally, parse the command line.
@@ -91,18 +112,40 @@ def show_themes() -> None:
91
112
  print(theme)
92
113
 
93
114
 
115
+ ##############################################################################
116
+ def reset_news(args: Namespace) -> None:
117
+ """Perform a reset on the news data.
118
+
119
+ Args:
120
+ args: The command line arguments.
121
+ """
122
+ from rich.prompt import Confirm
123
+
124
+ logout = " and log you out" if args.logout else ""
125
+ if args.yes or Confirm().ask(
126
+ f"This will erase all the local news data{logout}; are you sure?", default=False
127
+ ):
128
+ reset_data(args.logout)
129
+ print("Local data erased")
130
+ if args.logout:
131
+ print("Login token removed")
132
+
133
+
94
134
  ##############################################################################
95
135
  def main() -> None:
96
136
  """Main entry function."""
97
- if (args := get_args()).license:
98
- print(cleandoc(OldNews.HELP_LICENSE))
99
- elif args.bindings:
100
- show_bindable_commands()
101
- elif args.theme == "?":
102
- show_themes()
103
- else:
104
- initialise_database()
105
- OldNews(args).run()
137
+ match (args := get_args()).command:
138
+ case "reset":
139
+ reset_news(args)
140
+ case "license" | "licence":
141
+ print(cleandoc(OldNews.HELP_LICENSE))
142
+ case "bindings":
143
+ show_bindable_commands()
144
+ case "themes":
145
+ show_themes()
146
+ case _:
147
+ initialise_database()
148
+ OldNews(args).run()
106
149
 
107
150
 
108
151
  ### __main__.py ends here
@@ -3,12 +3,16 @@
3
3
  ##############################################################################
4
4
  # Local imports.
5
5
  from .main import (
6
+ Copy,
7
+ CopyArticleToClipboard,
8
+ CopyFeedToClipboard,
9
+ CopyHomePageToClipboard,
6
10
  Escape,
7
11
  MarkAllRead,
8
12
  Next,
9
13
  NextUnread,
10
14
  OpenArticle,
11
- OpenOrigin,
15
+ OpenHomePage,
12
16
  Previous,
13
17
  PreviousUnread,
14
18
  RefreshFromTheOldReader,
@@ -18,12 +22,16 @@ from .main import (
18
22
  ##############################################################################
19
23
  # Exports.
20
24
  __all__ = [
25
+ "Copy",
26
+ "CopyArticleToClipboard",
27
+ "CopyFeedToClipboard",
28
+ "CopyHomePageToClipboard",
21
29
  "Escape",
22
30
  "MarkAllRead",
23
31
  "Next",
24
32
  "NextUnread",
25
33
  "OpenArticle",
26
- "OpenOrigin",
34
+ "OpenHomePage",
27
35
  "Previous",
28
36
  "PreviousUnread",
29
37
  "RefreshFromTheOldReader",
@@ -64,8 +64,8 @@ class OpenArticle(Command):
64
64
 
65
65
 
66
66
  ##############################################################################
67
- class OpenOrigin(Command):
68
- """Open the origin for the current article in the web browser"""
67
+ class OpenHomePage(Command):
68
+ """Open the home page for the current subscription in the web browser"""
69
69
 
70
70
  BINDING_KEY = "O"
71
71
 
@@ -77,4 +77,32 @@ class MarkAllRead(Command):
77
77
  BINDING_KEY = "R"
78
78
 
79
79
 
80
+ ##############################################################################
81
+ class CopyHomePageToClipboard(Command):
82
+ """Copy the URL of the current subscription's home page to the clipboard"""
83
+
84
+ BINDING_KEY = "f3"
85
+
86
+
87
+ ##############################################################################
88
+ class CopyFeedToClipboard(Command):
89
+ """Copy the URL of the current subscription's feed to the clipboard"""
90
+
91
+ BINDING_KEY = "f4"
92
+
93
+
94
+ ##############################################################################
95
+ class CopyArticleToClipboard(Command):
96
+ """Copy the URL for the current article to the clipboard"""
97
+
98
+ BINDING_KEY = "f5"
99
+
100
+
101
+ ##############################################################################
102
+ class Copy(Command):
103
+ """Copy a URL to the clipboard depending on the context"""
104
+
105
+ BINDING_KEY = "ctrl+c"
106
+
107
+
80
108
  ### main.py ends here
@@ -23,6 +23,7 @@ from .local_folders import get_local_folders, save_local_folders
23
23
  from .local_subscriptions import get_local_subscriptions, save_local_subscriptions
24
24
  from .local_unread import LocalUnread, get_local_unread, total_unread
25
25
  from .navigation_state import get_navigation_state, save_navigation_state
26
+ from .reset import reset_data
26
27
 
27
28
  ##############################################################################
28
29
  # Exports.
@@ -43,6 +44,7 @@ __all__ = [
43
44
  "locally_mark_article_ids_read",
44
45
  "LocalUnread",
45
46
  "remember_we_last_grabbed_at",
47
+ "reset_data",
46
48
  "save_configuration",
47
49
  "save_local_articles",
48
50
  "save_local_folders",
@@ -0,0 +1,26 @@
1
+ """Provides a tool to reset all the data."""
2
+
3
+ ##############################################################################
4
+ # Python imports.
5
+ from pathlib import Path
6
+
7
+ ##############################################################################
8
+ # Local imports.
9
+ from .locations import data_dir
10
+
11
+
12
+ ##############################################################################
13
+ def reset_data(logout: bool) -> None:
14
+ """Erase all the data.
15
+
16
+ Args:
17
+ logout: Should any token file be removed too?
18
+ """
19
+ to_remove: list[Path] = []
20
+ for pattern in ("*.table", "*.db", "*.log", *((".token",) if logout else ())):
21
+ to_remove.extend(data_dir().glob(pattern))
22
+ for data_file in to_remove:
23
+ data_file.unlink()
24
+
25
+
26
+ ### reset.py ends here
@@ -13,12 +13,16 @@ from textual_enhanced.commands import (
13
13
  ##############################################################################
14
14
  # Local imports.
15
15
  from ..commands import (
16
+ Copy,
17
+ CopyArticleToClipboard,
18
+ CopyFeedToClipboard,
19
+ CopyHomePageToClipboard,
16
20
  Escape,
17
21
  MarkAllRead,
18
22
  Next,
19
23
  NextUnread,
20
24
  OpenArticle,
21
- OpenOrigin,
25
+ OpenHomePage,
22
26
  Previous,
23
27
  PreviousUnread,
24
28
  RefreshFromTheOldReader,
@@ -42,8 +46,12 @@ class MainCommands(CommandsProvider):
42
46
  yield from self.maybe(Previous)
43
47
  yield from self.maybe(PreviousUnread)
44
48
  yield from self.maybe(OpenArticle)
45
- yield from self.maybe(OpenOrigin)
49
+ yield from self.maybe(OpenHomePage)
46
50
  yield from self.maybe(MarkAllRead)
51
+ yield from self.maybe(CopyHomePageToClipboard)
52
+ yield from self.maybe(CopyFeedToClipboard)
53
+ yield from self.maybe(CopyArticleToClipboard)
54
+ yield from self.maybe(Copy)
47
55
  yield ToggleShowAll()
48
56
  yield RefreshFromTheOldReader()
49
57
  yield ChangeTheme()
@@ -3,14 +3,13 @@
3
3
  ##############################################################################
4
4
  # Python imports.
5
5
  from dataclasses import dataclass
6
- from datetime import datetime, timedelta, timezone
6
+ from datetime import datetime, timedelta
7
7
  from webbrowser import open as open_url
8
8
 
9
9
  ##############################################################################
10
10
  # OldAs imports.
11
11
  from oldas import (
12
12
  Article,
13
- ArticleIDs,
14
13
  Articles,
15
14
  Folder,
16
15
  Folders,
@@ -39,12 +38,16 @@ from textual_enhanced.screen import EnhancedScreen
39
38
  # Local imports.
40
39
  from .. import __version__
41
40
  from ..commands import (
41
+ Copy,
42
+ CopyArticleToClipboard,
43
+ CopyFeedToClipboard,
44
+ CopyHomePageToClipboard,
42
45
  Escape,
43
46
  MarkAllRead,
44
47
  Next,
45
48
  NextUnread,
46
49
  OpenArticle,
47
- OpenOrigin,
50
+ OpenHomePage,
48
51
  Previous,
49
52
  PreviousUnread,
50
53
  RefreshFromTheOldReader,
@@ -57,19 +60,15 @@ from ..data import (
57
60
  get_local_folders,
58
61
  get_local_subscriptions,
59
62
  get_local_unread,
60
- get_unread_article_ids,
61
63
  last_grabbed_data_at,
62
64
  load_configuration,
63
65
  locally_mark_article_ids_read,
64
66
  locally_mark_read,
65
- remember_we_last_grabbed_at,
66
- save_local_articles,
67
- save_local_folders,
68
- save_local_subscriptions,
69
67
  total_unread,
70
68
  update_configuration,
71
69
  )
72
70
  from ..providers import MainCommands
71
+ from ..sync import ToRSync
73
72
  from ..widgets import ArticleContent, ArticleList, Navigation
74
73
 
75
74
 
@@ -122,6 +121,13 @@ class Main(EnhancedScreen[None]):
122
121
  width: 25%;
123
122
  }
124
123
 
124
+ #article-view {
125
+ display: none;
126
+ &.--has-articles {
127
+ display: block;
128
+ }
129
+ }
130
+
125
131
  ArticleList {
126
132
  height: 1fr;
127
133
  }
@@ -147,8 +153,12 @@ class Main(EnhancedScreen[None]):
147
153
  Previous,
148
154
  PreviousUnread,
149
155
  OpenArticle,
150
- OpenOrigin,
156
+ OpenHomePage,
151
157
  ChangeTheme,
158
+ CopyHomePageToClipboard,
159
+ CopyFeedToClipboard,
160
+ CopyArticleToClipboard,
161
+ Copy,
152
162
  ]
153
163
 
154
164
  BINDINGS = Command.bindings(*COMMAND_MESSAGES)
@@ -198,6 +208,9 @@ class Main(EnhancedScreen[None]):
198
208
  counts: LocalUnread
199
209
  """The new unread counts."""
200
210
 
211
+ class SyncFinished(Message):
212
+ """Message sent when a sync from TheOldReader is finished."""
213
+
201
214
  def __init__(self, session: Session) -> None:
202
215
  """Initialise the main screen."""
203
216
  super().__init__()
@@ -210,7 +223,7 @@ class Main(EnhancedScreen[None]):
210
223
  yield Navigation(classes="panel").data_bind(
211
224
  Main.folders, Main.subscriptions, Main.unread
212
225
  )
213
- with Vertical():
226
+ with Vertical(id="article-view"):
214
227
  yield ArticleList(classes="panel").data_bind(
215
228
  Main.articles, Main.current_category
216
229
  )
@@ -239,8 +252,14 @@ class Main(EnhancedScreen[None]):
239
252
  # but okay let's be defensive... (when I can come up with a nice
240
253
  # little MRE I'll report it).
241
254
  return True
242
- if action in (OpenArticle.action_name(), OpenOrigin.action_name()):
255
+ if action in (OpenArticle.action_name(), CopyArticleToClipboard.action_name()):
243
256
  return self.article is not None
257
+ if action in (
258
+ OpenHomePage.action_name(),
259
+ CopyFeedToClipboard.action_name(),
260
+ CopyHomePageToClipboard.action_name(),
261
+ ):
262
+ return self.query_one(Navigation).current_subscription is not None
244
263
  if action in (Next.action_name(), Previous.action_name()):
245
264
  return self.articles is not None
246
265
  if action in (
@@ -257,6 +276,11 @@ class Main(EnhancedScreen[None]):
257
276
  return self.articles is not None and any(
258
277
  article.is_unread for article in self.articles
259
278
  )
279
+ if action == Copy.action_name():
280
+ return (
281
+ (navigation := self.query_one(Navigation)).has_focus
282
+ and navigation.current_subscription is not None
283
+ ) or self.query_one("#article-view").has_focus_within
260
284
  return True
261
285
 
262
286
  @on(SubTitle)
@@ -298,6 +322,18 @@ class Main(EnhancedScreen[None]):
298
322
  self.unread = message.counts
299
323
  self.post_message(self.SubTitle(""))
300
324
 
325
+ def _refresh_article_list(self) -> None:
326
+ """Refresh the content of the article list."""
327
+ if self.current_category:
328
+ self.articles = get_local_articles(self.current_category, not self.show_all)
329
+ # If the result is there's nothing showing, tidy up the content
330
+ # side of the display and maybe move focus back to navigation.
331
+ if not self.articles:
332
+ self.article = None
333
+ if self.query_one("#article-view").has_focus_within:
334
+ self.query_one(Navigation).focus()
335
+ self.query_one("#article-view").set_class(bool(self.articles), "--has-articles")
336
+
301
337
  @work(thread=True, exclusive=True)
302
338
  def _load_locally(self) -> None:
303
339
  """Load up any locally-held data."""
@@ -311,6 +347,7 @@ class Main(EnhancedScreen[None]):
311
347
  self.notify(f"Old read articles cleaned from local storage: {cleaned}")
312
348
  if unread := get_local_unread(folders, subscriptions):
313
349
  self.post_message(self.NewUnread(unread))
350
+ self._refresh_article_list()
314
351
  # If we've never grabbed data from ToR before, or if it's been long enough...
315
352
  if (last_grabbed := last_grabbed_data_at()) is None or (
316
353
  (datetime.now() - last_grabbed).seconds
@@ -319,95 +356,27 @@ class Main(EnhancedScreen[None]):
319
356
  # ...kick off a refresh from TheOldReader.
320
357
  self.post_message(RefreshFromTheOldReader())
321
358
 
322
- async def _download_newest_articles(self) -> None:
323
- """Download the latest articles available."""
324
- last_grabbed = last_grabbed_data_at() or (
325
- datetime.now() - timedelta(days=load_configuration().local_history)
326
- )
327
- new_grab = datetime.now(timezone.utc)
328
- loaded = 0
329
- async for article in Articles.stream_new_since(
330
- self._session, last_grabbed, n=10
331
- ):
332
- # I've encountered articles that don't have an origin stream ID,
333
- # which means that I can't relate them back to a stream, which
334
- # means I'll never see them anyway...
335
- if not article.origin.stream_id:
336
- continue
337
- # TODO: Right now I'm saving articles one at a time; perhaps I
338
- # should save them in small batches? This would be simple enough
339
- # -- perhaps same them in batches the same size as the buffer
340
- # window I'm using right now (currently 10 articles per trip to
341
- # ToR).
342
- save_local_articles(Articles([article]))
343
- loaded += 1
344
- if (loaded % 10) == 0:
345
- self.post_message(
346
- self.SubTitle(f"Downloading articles from TheOldReader: {loaded}")
347
- )
348
- if loaded:
349
- self.notify(f"Articles downloaded: {loaded}")
350
- else:
351
- self.notify("No new articles found on TheOldReader")
352
- remember_we_last_grabbed_at(new_grab)
353
-
354
- async def _refresh_read_status(self) -> None:
355
- """Refresh the read status from the server."""
356
- self.post_message(
357
- self.SubTitle("Getting list of unread articles from TheOldReader")
358
- )
359
- remote_unread_articles = set(
360
- article_id.full_id
361
- for article_id in await ArticleIDs.load_unread(self._session)
362
- )
363
- self.post_message(self.SubTitle("Comparing against locally-read articles"))
364
- local_unread_articles = set(get_unread_article_ids())
365
- if mark_as_read := local_unread_articles - remote_unread_articles:
366
- locally_mark_article_ids_read(mark_as_read)
367
- self.notify(
368
- f"Articles found read elsewhere on TheOldReader: {len(mark_as_read)}"
369
- )
359
+ @on(SyncFinished)
360
+ def _sync_finished(self) -> None:
361
+ """Clean up after a sync from TheOldReader has finished."""
362
+ self._refresh_article_list()
363
+ self.post_message(self.SubTitle(""))
370
364
 
371
365
  @on(RefreshFromTheOldReader)
372
366
  @work(exclusive=True)
373
367
  async def action_refresh_from_the_old_reader_command(self) -> None:
374
368
  """Load the main data from TheOldReader."""
375
-
376
- # Get the folder list.
377
- self.post_message(self.SubTitle("Getting folder list"))
378
- self.post_message(
379
- self.NewFolders(save_local_folders(await Folders.load(self._session)))
380
- )
381
-
382
- # Get the subscriptions list.
383
- self.post_message(self.SubTitle("Getting subscriptions list"))
384
- self.post_message(
385
- self.NewSubscriptions(
386
- save_local_subscriptions(await Subscriptions.load(self._session))
387
- )
388
- )
389
-
390
- # Download the latest articles we don't know about.
391
- if never_grabbed_before := last_grabbed_data_at() is None:
392
- self.post_message(self.SubTitle("Getting available articles"))
393
- else:
394
- self.post_message(
395
- self.SubTitle(f"Getting articles new since {last_grabbed_data_at()}")
396
- )
397
- await self._download_newest_articles()
398
-
399
- # If we have grabbed data before, let's try and sync up what's been read.
400
- if not never_grabbed_before:
401
- await self._refresh_read_status()
402
-
403
- # Recalculate the unread counts.
404
- self.post_message(self.SubTitle("Calculating unread counts"))
405
- self.post_message(
406
- self.NewUnread(get_local_unread(self.folders, self.subscriptions))
407
- )
408
-
409
- # Finally we're all done.
410
- self.post_message(self.SubTitle(""))
369
+ await ToRSync(
370
+ self._session,
371
+ on_new_step=lambda step: self.post_message(self.SubTitle(step)),
372
+ on_new_result=lambda result: self.notify(result),
373
+ on_new_folders=lambda folders: self.post_message(self.NewFolders(folders)),
374
+ on_new_subscriptions=lambda subscriptions: self.post_message(
375
+ self.NewSubscriptions(subscriptions)
376
+ ),
377
+ on_new_unread=lambda unread: self.post_message(self.NewUnread(unread)),
378
+ on_sync_finished=lambda: self.post_message(self.SyncFinished()),
379
+ ).refresh()
411
380
 
412
381
  @on(Navigation.CategorySelected)
413
382
  def _handle_navigaion_selection(self, message: Navigation.CategorySelected) -> None:
@@ -418,14 +387,9 @@ class Main(EnhancedScreen[None]):
418
387
  """
419
388
  self.current_category = message.category
420
389
  self.article = None
421
- self.articles = get_local_articles(message.category, not self.show_all)
390
+ self._refresh_article_list()
422
391
  self.query_one(ArticleList).focus()
423
392
 
424
- def _refresh_article_list(self) -> None:
425
- """Refresh the content of the article list."""
426
- if category := self.query_one(Navigation).current_category:
427
- self.articles = get_local_articles(category, not self.show_all)
428
-
429
393
  def _watch_show_all(self) -> None:
430
394
  """Handle changes to the show all flag."""
431
395
  self._refresh_article_list()
@@ -563,17 +527,62 @@ class Main(EnhancedScreen[None]):
563
527
  severity="error",
564
528
  )
565
529
 
566
- def action_open_origin_command(self) -> None:
567
- """Open the origin of the current article in the web browser."""
568
- if self.article is not None:
569
- if self.article.origin.html_url:
570
- open_url(self.article.origin.html_url)
530
+ def action_open_home_page_command(self) -> None:
531
+ """Open the home page of the current subscription in the web browser."""
532
+ if subscription := self.query_one(Navigation).current_subscription:
533
+ if subscription.html_url:
534
+ open_url(subscription.html_url)
571
535
  else:
572
536
  self.notify(
573
- "No URL available for the article's origin",
537
+ "No home page URL available for the subscription",
574
538
  severity="error",
575
539
  title="Can't visit",
576
540
  )
577
541
 
542
+ def _copy_to_clipboard(self, content: str | None, empty_error: str) -> None:
543
+ """Copy some content to the clipboard.
544
+
545
+ Args:
546
+ content: The content to copy to the clipboard.
547
+ empty_error: The message to show if there's no content.
548
+ """
549
+ if content:
550
+ self.app.copy_to_clipboard(content)
551
+ self.notify("Copied to clipboard")
552
+ else:
553
+ self.notify(empty_error, severity="error", title="Can't copy")
554
+
555
+ def action_copy_home_page_to_clipboard_command(self) -> None:
556
+ """Copy the URL of the current subscription's homepage to the clipboard."""
557
+ if subscription := self.query_one(Navigation).current_subscription:
558
+ self._copy_to_clipboard(
559
+ subscription.html_url, "No home page URL available for the subscription"
560
+ )
561
+
562
+ def action_copy_feed_to_clipboard_command(self) -> None:
563
+ """Copy the URL of the current subscription's feed to the clipboard."""
564
+ if subscription := self.query_one(Navigation).current_subscription:
565
+ self._copy_to_clipboard(
566
+ subscription.url, "No feed URL available for the subscription"
567
+ )
568
+
569
+ def action_copy_article_to_clipboard_command(self) -> None:
570
+ """Copy the URL of the current article to the clipboard."""
571
+ if self.article:
572
+ self._copy_to_clipboard(
573
+ self.article.html_url, "No URL available for the article"
574
+ )
575
+
576
+ def action_copy_command(self) -> None:
577
+ """Copy a URL to the clipboard depending on the current context."""
578
+ if (navigation := self.query_one(Navigation)).has_focus:
579
+ if navigation.current_subscription:
580
+ self.action_copy_home_page_to_clipboard_command()
581
+ elif self.query_one("#article-view").has_focus_within:
582
+ if self.article:
583
+ self.action_copy_article_to_clipboard_command()
584
+ else:
585
+ self.action_copy_home_page_to_clipboard_command()
586
+
578
587
 
579
588
  ### main.py ends here
@@ -0,0 +1,158 @@
1
+ """Provides a class to sync data from TheOldReader."""
2
+
3
+ ##############################################################################
4
+ # Python imports.
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import Any, Callable
8
+
9
+ ##############################################################################
10
+ # OldAS imports.
11
+ from oldas import ArticleIDs, Articles, Folders, Session, Subscriptions
12
+
13
+ ##############################################################################
14
+ from .data import (
15
+ LocalUnread,
16
+ get_local_unread,
17
+ get_unread_article_ids,
18
+ last_grabbed_data_at,
19
+ load_configuration,
20
+ locally_mark_article_ids_read,
21
+ remember_we_last_grabbed_at,
22
+ save_local_articles,
23
+ save_local_folders,
24
+ save_local_subscriptions,
25
+ )
26
+
27
+ ##############################################################################
28
+ type Callback = Callable[[], Any] | None
29
+ """Type of a callback with no arguments."""
30
+ type CallbackWith[T] = Callable[[T], Any] | None
31
+ """Type of callback with a single argument."""
32
+
33
+
34
+ ##############################################################################
35
+ @dataclass
36
+ class ToRSync:
37
+ """Class that handles syncing data from TheOldReader."""
38
+
39
+ session: Session
40
+ """The TheOldReader API session object."""
41
+ on_new_step: CallbackWith[str] = None
42
+ """Function to call when a new step starts."""
43
+ on_new_result: CallbackWith[str] = None
44
+ """Function to call when a result should be communicated."""
45
+ on_new_folders: CallbackWith[Folders] = None
46
+ """Function to call when new folders are acquired."""
47
+ on_new_subscriptions: CallbackWith[Subscriptions] = None
48
+ """Function to call when new subscriptions are acquired."""
49
+ on_new_unread: CallbackWith[LocalUnread] = None
50
+ """Function to call when new unread counts are calculated."""
51
+ on_sync_finished: Callback = None
52
+ """Function to call when the sync has finished."""
53
+
54
+ def _step(self, step: str) -> None:
55
+ """Mark a new step.
56
+
57
+ Args:
58
+ step: The step that is happening.
59
+ """
60
+ if self.on_new_step:
61
+ self.on_new_step(step)
62
+
63
+ def _result(self, result: str) -> None:
64
+ """Show a new result.
65
+
66
+ Args:
67
+ result: The result that should be shown.
68
+ """
69
+ if self.on_new_result:
70
+ self.on_new_result(result)
71
+
72
+ async def _download_newest_articles(self) -> None:
73
+ """Download the latest articles available."""
74
+ new_grab = datetime.now(timezone.utc)
75
+ last_grabbed = last_grabbed_data_at() or (
76
+ new_grab - timedelta(days=load_configuration().local_history)
77
+ )
78
+ loaded = 0
79
+ async for article in Articles.stream_new_since(
80
+ self.session, last_grabbed, n=10
81
+ ):
82
+ # I've encountered articles that don't have an origin stream ID,
83
+ # which means that I can't relate them back to a stream, which
84
+ # means I'll never see them anyway...
85
+ if not article.origin.stream_id:
86
+ continue
87
+ # TODO: Right now I'm saving articles one at a time; perhaps I
88
+ # should save them in small batches? This would be simple enough
89
+ # -- perhaps same them in batches the same size as the buffer
90
+ # window I'm using right now (currently 10 articles per trip to
91
+ # ToR).
92
+ save_local_articles(Articles([article]))
93
+ loaded += 1
94
+ if (loaded % 10) == 0:
95
+ self._step(f"Downloading articles from TheOldReader: {loaded}")
96
+ if loaded:
97
+ self._result(f"Articles downloaded: {loaded}")
98
+ else:
99
+ self._result("No new articles found on TheOldReader")
100
+ remember_we_last_grabbed_at(new_grab)
101
+
102
+ async def _refresh_read_status(self) -> None:
103
+ """Refresh the read status from the server."""
104
+ self._step("Getting list of unread articles from TheOldReader")
105
+ remote_unread_articles = set(
106
+ article_id.full_id
107
+ for article_id in await ArticleIDs.load_unread(self.session)
108
+ )
109
+ self._step("Comparing against locally-read articles")
110
+ local_unread_articles = set(get_unread_article_ids())
111
+ if mark_as_read := local_unread_articles - remote_unread_articles:
112
+ locally_mark_article_ids_read(mark_as_read)
113
+ self._result(
114
+ f"Articles found read elsewhere on TheOldReader: {len(mark_as_read)}"
115
+ )
116
+
117
+ async def refresh(self) -> None:
118
+ """Refresh the data from TheOldReader.
119
+
120
+ Args:
121
+ session: The TheOldReader API session object.
122
+ """
123
+
124
+ # Get the folder list.
125
+ self._step("Getting folder list")
126
+ folders = save_local_folders(await Folders.load(self.session))
127
+ if self.on_new_folders:
128
+ self.on_new_folders(folders)
129
+
130
+ # Get the subscriptions list.
131
+ self._step("Getting subscriptions list")
132
+ subscriptions = save_local_subscriptions(await Subscriptions.load(self.session))
133
+ if self.on_new_subscriptions:
134
+ self.on_new_subscriptions(subscriptions)
135
+
136
+ # Download the latest articles we don't know about.
137
+ if never_grabbed_before := ((last_grab := last_grabbed_data_at()) is None):
138
+ self._step("Getting available articles")
139
+ else:
140
+ self._step(f"Getting new articles since {last_grab}")
141
+ await self._download_newest_articles()
142
+
143
+ # If we have grabbed data before, let's try and sync up what's been read.
144
+ if not never_grabbed_before:
145
+ await self._refresh_read_status()
146
+
147
+ # Recalculate the unread counts.
148
+ self._step("Calculating unread counts")
149
+ unread = get_local_unread(folders, subscriptions)
150
+ if self.on_new_unread:
151
+ self.on_new_unread(unread)
152
+
153
+ # Finally we're all done.
154
+ if self.on_sync_finished:
155
+ self.on_sync_finished()
156
+
157
+
158
+ ### sync.py ends here
@@ -17,7 +17,7 @@ from oldas import Article
17
17
  from textual.app import ComposeResult
18
18
  from textual.containers import Horizontal, Vertical, VerticalScroll
19
19
  from textual.reactive import var
20
- from textual.widgets import Label, Markdown
20
+ from textual.widgets import Label, Markdown, Rule
21
21
 
22
22
 
23
23
  ##############################################################################
@@ -32,6 +32,10 @@ class ArticleContent(Vertical):
32
32
  display: block;
33
33
  }
34
34
 
35
+ Rule.-horizontal {
36
+ margin: 0;
37
+ }
38
+
35
39
  #header {
36
40
  height: auto;
37
41
  padding: 0 1 0 1;
@@ -49,6 +53,15 @@ class ArticleContent(Vertical):
49
53
  }
50
54
  }
51
55
 
56
+ &:focus-within {
57
+ #header, Rule {
58
+ background: $boost;
59
+ }
60
+ Rule {
61
+ color: $border;
62
+ }
63
+ }
64
+
52
65
  Markdown {
53
66
  padding: 0 1 0 1;
54
67
  }
@@ -67,11 +80,13 @@ class ArticleContent(Vertical):
67
80
 
68
81
  def compose(self) -> ComposeResult:
69
82
  """Compose the content of the widget."""
83
+ yield Rule()
70
84
  with Vertical(id="header"):
71
85
  with Horizontal():
72
86
  yield Label(id="title", markup=False)
73
87
  yield Label(id="published")
74
88
  yield Label(id="link", markup=False)
89
+ yield Rule()
75
90
  with VerticalScroll():
76
91
  yield Markdown()
77
92
 
@@ -87,7 +102,7 @@ class ArticleContent(Vertical):
87
102
  link.visible = True
88
103
  link.update(self.article.html_url)
89
104
  await self.query_one(Markdown).update(convert(self.article.summary.content))
90
- self.query_one(VerticalScroll).scroll_home()
105
+ self.query_one(VerticalScroll).scroll_home(animate=False)
91
106
  self.set_class(self.article is not None, "--has-article")
92
107
 
93
108
  def focus(self, scroll_visible: bool = True) -> Self:
@@ -238,6 +238,20 @@ class Navigation(EnhancedOptionList):
238
238
  return selected.subscription
239
239
  raise ValueError("Unknown category")
240
240
 
241
+ @property
242
+ def current_folder(self) -> Folder | None:
243
+ """The current folder, if one is highlighted, or `None`"""
244
+ if isinstance(current := self.current_category, Folder):
245
+ return current
246
+ return None
247
+
248
+ @property
249
+ def current_subscription(self) -> Subscription | None:
250
+ """The current subscription, if one is highlighted, or `None`."""
251
+ if isinstance(current := self.current_category, Subscription):
252
+ return current
253
+ return None
254
+
241
255
  def _highlight_unread(self, direction: HighlightDirection) -> bool:
242
256
  """Highlight the next category with unread articles, if there is one.
243
257
 
File without changes
File without changes
File without changes
File without changes
File without changes