oldnews 0.1.0__tar.gz → 0.3.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 (33) hide show
  1. {oldnews-0.1.0 → oldnews-0.3.0}/PKG-INFO +3 -4
  2. {oldnews-0.1.0 → oldnews-0.3.0}/pyproject.toml +3 -4
  3. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/__main__.py +67 -24
  4. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/commands/__init__.py +2 -0
  5. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/commands/main.py +7 -0
  6. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/__init__.py +4 -0
  7. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/db.py +15 -9
  8. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/local_articles.py +18 -3
  9. oldnews-0.3.0/src/oldnews/data/reset.py +26 -0
  10. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/providers/main.py +2 -0
  11. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/screens/main.py +60 -100
  12. oldnews-0.3.0/src/oldnews/sync/__init__.py +11 -0
  13. oldnews-0.3.0/src/oldnews/sync/sync.py +158 -0
  14. oldnews-0.3.0/src/oldnews/widgets/_after_highlight.py +53 -0
  15. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/widgets/article_content.py +29 -7
  16. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/widgets/article_list.py +19 -37
  17. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/widgets/navigation.py +36 -1
  18. {oldnews-0.1.0 → oldnews-0.3.0}/README.md +0 -0
  19. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/__init__.py +0 -0
  20. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/auth.py +0 -0
  21. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/config.py +0 -0
  22. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/last_grab.py +0 -0
  23. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/local_folders.py +0 -0
  24. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/local_subscriptions.py +0 -0
  25. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/local_unread.py +0 -0
  26. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/locations.py +0 -0
  27. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/navigation_state.py +0 -0
  28. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/oldnews.py +0 -0
  29. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/providers/__init__.py +0 -0
  30. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/py.typed +0 -0
  31. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/screens/__init__.py +0 -0
  32. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/screens/login.py +0 -0
  33. {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/widgets/__init__.py +0 -0
@@ -1,16 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oldnews
3
- Version: 0.1.0
3
+ Version: 0.3.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
13
- Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
16
15
  Classifier: Programming Language :: Python :: 3.14
@@ -21,7 +20,7 @@ Requires-Dist: textual>=6.3.0
21
20
  Requires-Dist: textual-enhanced>=1.2.0
22
21
  Requires-Dist: typedal>=4.2.2
23
22
  Requires-Dist: xdg-base-dirs>=6.0.2
24
- Requires-Python: >=3.11
23
+ Requires-Python: >=3.12
25
24
  Project-URL: Homepage, https://github.com/davep/oldnews
26
25
  Project-URL: Repository, https://github.com/davep/oldnews
27
26
  Project-URL: Documentation, https://oldnews.davep.dev/
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "oldnews"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "A terminal-based client for TheOldReader"
5
5
  readme = "README.md"
6
6
  authors = [
7
7
  { name = "Dave Pearson", email = "davep@davep.org" }
8
8
  ]
9
- requires-python = ">=3.11"
9
+ requires-python = ">=3.12"
10
10
  license = "GPL-3.0-or-later"
11
11
  keywords = [
12
12
  "atom",
@@ -27,11 +27,10 @@ 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",
34
- "Programming Language :: Python :: 3.11",
35
34
  "Programming Language :: Python :: 3.12",
36
35
  "Programming Language :: Python :: 3.13",
37
36
  "Programming Language :: Python :: 3.14",
@@ -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
@@ -8,6 +8,7 @@ from .main import (
8
8
  Next,
9
9
  NextUnread,
10
10
  OpenArticle,
11
+ OpenOrigin,
11
12
  Previous,
12
13
  PreviousUnread,
13
14
  RefreshFromTheOldReader,
@@ -22,6 +23,7 @@ __all__ = [
22
23
  "Next",
23
24
  "NextUnread",
24
25
  "OpenArticle",
26
+ "OpenOrigin",
25
27
  "Previous",
26
28
  "PreviousUnread",
27
29
  "RefreshFromTheOldReader",
@@ -63,6 +63,13 @@ class OpenArticle(Command):
63
63
  BINDING_KEY = "o"
64
64
 
65
65
 
66
+ ##############################################################################
67
+ class OpenOrigin(Command):
68
+ """Open the origin for the current article in the web browser"""
69
+
70
+ BINDING_KEY = "O"
71
+
72
+
66
73
  ##############################################################################
67
74
  class MarkAllRead(Command):
68
75
  """Mark all unread articles in the current category as read"""
@@ -12,6 +12,7 @@ from .config import (
12
12
  from .db import initialise_database
13
13
  from .last_grab import last_grabbed_data_at, remember_we_last_grabbed_at
14
14
  from .local_articles import (
15
+ clean_old_read_articles,
15
16
  get_local_articles,
16
17
  get_unread_article_ids,
17
18
  locally_mark_article_ids_read,
@@ -22,11 +23,13 @@ from .local_folders import get_local_folders, save_local_folders
22
23
  from .local_subscriptions import get_local_subscriptions, save_local_subscriptions
23
24
  from .local_unread import LocalUnread, get_local_unread, total_unread
24
25
  from .navigation_state import get_navigation_state, save_navigation_state
26
+ from .reset import reset_data
25
27
 
26
28
  ##############################################################################
27
29
  # Exports.
28
30
  __all__ = [
29
31
  "Configuration",
32
+ "clean_old_read_articles",
30
33
  "get_auth_token",
31
34
  "get_local_articles",
32
35
  "get_local_folders",
@@ -41,6 +44,7 @@ __all__ = [
41
44
  "locally_mark_article_ids_read",
42
45
  "LocalUnread",
43
46
  "remember_we_last_grabbed_at",
47
+ "reset_data",
44
48
  "save_configuration",
45
49
  "save_local_articles",
46
50
  "save_local_folders",
@@ -3,11 +3,14 @@
3
3
  ##############################################################################
4
4
  # Python imports.
5
5
  from pathlib import Path
6
+ from typing import Any
6
7
 
7
8
  ##############################################################################
8
9
  # TypeDAL imports.
9
- from typedal import TypeDAL, TypedTable
10
+ from typedal import TypeDAL, TypedField, TypedTable
10
11
  from typedal.config import TypeDALConfig
12
+ from typedal.helpers import get_field
13
+ from typedal.types import Field
11
14
 
12
15
  ##############################################################################
13
16
  # Local imports.
@@ -30,7 +33,9 @@ def db_file() -> Path:
30
33
 
31
34
 
32
35
  ##############################################################################
33
- def _safely_index(table: type[TypedTable], name: str, field: str) -> None:
36
+ def _safely_index(
37
+ table: type[TypedTable], name: str, field: str | Field | TypedField[Any]
38
+ ) -> None:
34
39
  """Create an index on a type, but handle errors.
35
40
 
36
41
  Args:
@@ -45,7 +50,9 @@ def _safely_index(table: type[TypedTable], name: str, field: str) -> None:
45
50
  just missed it.
46
51
  """
47
52
  try:
48
- table.create_index(name, field)
53
+ table.create_index(
54
+ name, get_field(field) if isinstance(field, TypedField) else field
55
+ )
49
56
  except RuntimeError:
50
57
  pass
51
58
 
@@ -74,12 +81,11 @@ def initialise_database() -> TypeDAL:
74
81
  )
75
82
 
76
83
  dal.define(LocalArticleCategory)
77
- # TODO: Need to make `field` more open.
78
- # _safely_index(
79
- # LocalArticleCategory,
80
- # "idx_local_article_category_article",
81
- # LocalArticleCategory.article,
82
- # )
84
+ _safely_index(
85
+ LocalArticleCategory,
86
+ "idx_local_article_category_article",
87
+ LocalArticleCategory.article,
88
+ )
83
89
  _safely_index(
84
90
  LocalArticleCategory,
85
91
  "idx_local_article_category_category",
@@ -2,7 +2,7 @@
2
2
 
3
3
  ##############################################################################
4
4
  # Python imports.
5
- from datetime import datetime
5
+ from datetime import datetime, timedelta
6
6
  from html import unescape
7
7
  from typing import Iterable, Iterator, cast
8
8
 
@@ -85,7 +85,7 @@ class LocalArticle(TypedTable):
85
85
  class LocalArticleCategory(TypedTable):
86
86
  """A local copy of the categories associated with an article."""
87
87
 
88
- article: LocalArticle
88
+ article: TypedField[LocalArticle]
89
89
  """The article that this category belongs to."""
90
90
  category: str
91
91
  """The category."""
@@ -95,7 +95,7 @@ class LocalArticleCategory(TypedTable):
95
95
  class LocalArticleAlternate(TypedTable):
96
96
  """A local copy of the alternate URLs associated with an article."""
97
97
 
98
- article: LocalArticle
98
+ article: TypedField[LocalArticle]
99
99
  """The article that this alternate belongs to."""
100
100
  href: str
101
101
  """The URL of the alternate."""
@@ -333,4 +333,19 @@ def get_unread_article_ids() -> list[str]:
333
333
  ]
334
334
 
335
335
 
336
+ ##############################################################################
337
+ def clean_old_read_articles(cutoff: timedelta) -> int:
338
+ """Clean up articles that are older than the given cutoff time."""
339
+ assert LocalArticle._db is not None
340
+ read = get_local_read_article_ids()
341
+ retire_time = datetime.now() - cutoff
342
+ cleaned = len(
343
+ LocalArticle.where(
344
+ (LocalArticle.published < retire_time) & LocalArticle.id.belongs(read)
345
+ ).delete()
346
+ )
347
+ LocalArticle._db.commit()
348
+ return cleaned
349
+
350
+
336
351
  ### local_articles.py ends here
@@ -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
@@ -18,6 +18,7 @@ from ..commands import (
18
18
  Next,
19
19
  NextUnread,
20
20
  OpenArticle,
21
+ OpenOrigin,
21
22
  Previous,
22
23
  PreviousUnread,
23
24
  RefreshFromTheOldReader,
@@ -41,6 +42,7 @@ class MainCommands(CommandsProvider):
41
42
  yield from self.maybe(Previous)
42
43
  yield from self.maybe(PreviousUnread)
43
44
  yield from self.maybe(OpenArticle)
45
+ yield from self.maybe(OpenOrigin)
44
46
  yield from self.maybe(MarkAllRead)
45
47
  yield ToggleShowAll()
46
48
  yield RefreshFromTheOldReader()
@@ -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,
@@ -44,6 +43,7 @@ from ..commands import (
44
43
  Next,
45
44
  NextUnread,
46
45
  OpenArticle,
46
+ OpenOrigin,
47
47
  Previous,
48
48
  PreviousUnread,
49
49
  RefreshFromTheOldReader,
@@ -51,23 +51,20 @@ from ..commands import (
51
51
  )
52
52
  from ..data import (
53
53
  LocalUnread,
54
+ clean_old_read_articles,
54
55
  get_local_articles,
55
56
  get_local_folders,
56
57
  get_local_subscriptions,
57
58
  get_local_unread,
58
- get_unread_article_ids,
59
59
  last_grabbed_data_at,
60
60
  load_configuration,
61
61
  locally_mark_article_ids_read,
62
62
  locally_mark_read,
63
- remember_we_last_grabbed_at,
64
- save_local_articles,
65
- save_local_folders,
66
- save_local_subscriptions,
67
63
  total_unread,
68
64
  update_configuration,
69
65
  )
70
66
  from ..providers import MainCommands
67
+ from ..sync import ToRSync
71
68
  from ..widgets import ArticleContent, ArticleList, Navigation
72
69
 
73
70
 
@@ -145,6 +142,7 @@ class Main(EnhancedScreen[None]):
145
142
  Previous,
146
143
  PreviousUnread,
147
144
  OpenArticle,
145
+ OpenOrigin,
148
146
  ChangeTheme,
149
147
  ]
150
148
 
@@ -195,6 +193,9 @@ class Main(EnhancedScreen[None]):
195
193
  counts: LocalUnread
196
194
  """The new unread counts."""
197
195
 
196
+ class SyncFinished(Message):
197
+ """Message sent when a sync from TheOldReader is finished."""
198
+
198
199
  def __init__(self, session: Session) -> None:
199
200
  """Initialise the main screen."""
200
201
  super().__init__()
@@ -236,7 +237,7 @@ class Main(EnhancedScreen[None]):
236
237
  # but okay let's be defensive... (when I can come up with a nice
237
238
  # little MRE I'll report it).
238
239
  return True
239
- if action == OpenArticle.action_name():
240
+ if action in (OpenArticle.action_name(), OpenOrigin.action_name()):
240
241
  return self.article is not None
241
242
  if action in (Next.action_name(), Previous.action_name()):
242
243
  return self.articles is not None
@@ -245,6 +246,12 @@ class Main(EnhancedScreen[None]):
245
246
  PreviousUnread.action_name(),
246
247
  MarkAllRead.action_name(),
247
248
  ):
249
+ # If we're inside the navigation panel...
250
+ if self.query_one(Navigation).has_focus:
251
+ # ...we just care if there's anything unread somewhere.
252
+ return any(total for total in self.unread.values())
253
+ # Otherwise we care if we can see a current list of articles and
254
+ # if there's something unread amongst them.
248
255
  return self.articles is not None and any(
249
256
  article.is_unread for article in self.articles
250
257
  )
@@ -289,6 +296,11 @@ class Main(EnhancedScreen[None]):
289
296
  self.unread = message.counts
290
297
  self.post_message(self.SubTitle(""))
291
298
 
299
+ def _refresh_article_list(self) -> None:
300
+ """Refresh the content of the article list."""
301
+ if self.current_category:
302
+ self.articles = get_local_articles(self.current_category, not self.show_all)
303
+
292
304
  @work(thread=True, exclusive=True)
293
305
  def _load_locally(self) -> None:
294
306
  """Load up any locally-held data."""
@@ -296,8 +308,13 @@ class Main(EnhancedScreen[None]):
296
308
  self.post_message(self.NewFolders(folders))
297
309
  if subscriptions := get_local_subscriptions():
298
310
  self.post_message(self.NewSubscriptions(subscriptions))
311
+ if cleaned := clean_old_read_articles(
312
+ timedelta(days=load_configuration().local_history)
313
+ ):
314
+ self.notify(f"Old read articles cleaned from local storage: {cleaned}")
299
315
  if unread := get_local_unread(folders, subscriptions):
300
316
  self.post_message(self.NewUnread(unread))
317
+ self._refresh_article_list()
301
318
  # If we've never grabbed data from ToR before, or if it's been long enough...
302
319
  if (last_grabbed := last_grabbed_data_at()) is None or (
303
320
  (datetime.now() - last_grabbed).seconds
@@ -306,95 +323,27 @@ class Main(EnhancedScreen[None]):
306
323
  # ...kick off a refresh from TheOldReader.
307
324
  self.post_message(RefreshFromTheOldReader())
308
325
 
309
- async def _download_newest_articles(self) -> None:
310
- """Download the latest articles available."""
311
- last_grabbed = last_grabbed_data_at() or (
312
- datetime.now() - timedelta(days=load_configuration().local_history)
313
- )
314
- new_grab = datetime.now(timezone.utc)
315
- loaded = 0
316
- async for article in Articles.stream_new_since(
317
- self._session, last_grabbed, n=10
318
- ):
319
- # I've encountered articles that don't have an origin stream ID,
320
- # which means that I can't relate them back to a stream, which
321
- # means I'll never see them anyway...
322
- if not article.origin.stream_id:
323
- continue
324
- # TODO: Right now I'm saving articles one at a time; perhaps I
325
- # should save them in small batches? This would be simple enough
326
- # -- perhaps same them in batches the same size as the buffer
327
- # window I'm using right now (currently 10 articles per trip to
328
- # ToR).
329
- save_local_articles(Articles([article]))
330
- loaded += 1
331
- if (loaded % 10) == 0:
332
- self.post_message(
333
- self.SubTitle(f"Downloading articles from TheOldReader: {loaded}")
334
- )
335
- if loaded:
336
- self.notify(f"Articles downloaded: {loaded}")
337
- else:
338
- self.notify("No new articles found on TheOldReader")
339
- remember_we_last_grabbed_at(new_grab)
340
-
341
- async def _refresh_read_status(self) -> None:
342
- """Refresh the read status from the server."""
343
- self.post_message(
344
- self.SubTitle("Getting list of unread articles from TheOldReader")
345
- )
346
- remote_unread_articles = set(
347
- article_id.full_id
348
- for article_id in await ArticleIDs.load_unread(self._session)
349
- )
350
- self.post_message(self.SubTitle("Comparing against locally-read articles"))
351
- local_unread_articles = set(get_unread_article_ids())
352
- if mark_as_read := local_unread_articles - remote_unread_articles:
353
- locally_mark_article_ids_read(mark_as_read)
354
- self.notify(
355
- f"Articles found read elsewhere on TheOldReader: {len(mark_as_read)}"
356
- )
326
+ @on(SyncFinished)
327
+ def _sync_finished(self) -> None:
328
+ """Clean up after a sync from TheOldReader has finished."""
329
+ self._refresh_article_list()
330
+ self.post_message(self.SubTitle(""))
357
331
 
358
332
  @on(RefreshFromTheOldReader)
359
333
  @work(exclusive=True)
360
334
  async def action_refresh_from_the_old_reader_command(self) -> None:
361
335
  """Load the main data from TheOldReader."""
362
-
363
- # Get the folder list.
364
- self.post_message(self.SubTitle("Getting folder list"))
365
- self.post_message(
366
- self.NewFolders(save_local_folders(await Folders.load(self._session)))
367
- )
368
-
369
- # Get the subscriptions list.
370
- self.post_message(self.SubTitle("Getting subscriptions list"))
371
- self.post_message(
372
- self.NewSubscriptions(
373
- save_local_subscriptions(await Subscriptions.load(self._session))
374
- )
375
- )
376
-
377
- # Download the latest articles we don't know about.
378
- if never_grabbed_before := last_grabbed_data_at() is None:
379
- self.post_message(self.SubTitle("Getting available articles"))
380
- else:
381
- self.post_message(
382
- self.SubTitle(f"Getting articles new since {last_grabbed_data_at()}")
383
- )
384
- await self._download_newest_articles()
385
-
386
- # If we have grabbed data before, let's try and sync up what's been read.
387
- if not never_grabbed_before:
388
- await self._refresh_read_status()
389
-
390
- # Recalculate the unread counts.
391
- self.post_message(self.SubTitle("Calculating unread counts"))
392
- self.post_message(
393
- self.NewUnread(get_local_unread(self.folders, self.subscriptions))
394
- )
395
-
396
- # Finally we're all done.
397
- self.post_message(self.SubTitle(""))
336
+ await ToRSync(
337
+ self._session,
338
+ on_new_step=lambda step: self.post_message(self.SubTitle(step)),
339
+ on_new_result=lambda result: self.notify(result),
340
+ on_new_folders=lambda folders: self.post_message(self.NewFolders(folders)),
341
+ on_new_subscriptions=lambda subscriptions: self.post_message(
342
+ self.NewSubscriptions(subscriptions)
343
+ ),
344
+ on_new_unread=lambda unread: self.post_message(self.NewUnread(unread)),
345
+ on_sync_finished=lambda: self.post_message(self.SyncFinished()),
346
+ ).refresh()
398
347
 
399
348
  @on(Navigation.CategorySelected)
400
349
  def _handle_navigaion_selection(self, message: Navigation.CategorySelected) -> None:
@@ -405,14 +354,9 @@ class Main(EnhancedScreen[None]):
405
354
  """
406
355
  self.current_category = message.category
407
356
  self.article = None
408
- self.articles = get_local_articles(message.category, not self.show_all)
357
+ self._refresh_article_list()
409
358
  self.query_one(ArticleList).focus()
410
359
 
411
- def _refresh_article_list(self) -> None:
412
- """Refresh the content of the article list."""
413
- if category := self.query_one(Navigation).current_category:
414
- self.articles = get_local_articles(category, not self.show_all)
415
-
416
360
  def _watch_show_all(self) -> None:
417
361
  """Handle changes to the show all flag."""
418
362
  self._refresh_article_list()
@@ -487,14 +431,18 @@ class Main(EnhancedScreen[None]):
487
431
 
488
432
  def action_next_unread_command(self) -> None:
489
433
  """Go to the next unread article in the currently-viewed category."""
490
- if self.article is None:
434
+ if (navigation := self.query_one(Navigation)).has_focus:
435
+ navigation.highlight_next_unread_category()
436
+ elif self.article is None:
491
437
  self.query_one(ArticleList).highlight_next_unread_article()
492
438
  else:
493
439
  self.query_one(ArticleList).select_next_unread_article()
494
440
 
495
441
  def action_previous_unread_command(self) -> None:
496
442
  """Go to the previous unread article in the currently-viewed category"""
497
- if self.article is None:
443
+ if (navigation := self.query_one(Navigation)).has_focus:
444
+ navigation.highlight_previous_unread_category()
445
+ elif self.article is None:
498
446
  self.query_one(ArticleList).highlight_previous_unread_article()
499
447
  else:
500
448
  self.query_one(ArticleList).select_previous_unread_article()
@@ -546,5 +494,17 @@ class Main(EnhancedScreen[None]):
546
494
  severity="error",
547
495
  )
548
496
 
497
+ def action_open_origin_command(self) -> None:
498
+ """Open the origin of the current article in the web browser."""
499
+ if self.article is not None:
500
+ if self.article.origin.html_url:
501
+ open_url(self.article.origin.html_url)
502
+ else:
503
+ self.notify(
504
+ "No URL available for the article's origin",
505
+ severity="error",
506
+ title="Can't visit",
507
+ )
508
+
549
509
 
550
510
  ### main.py ends here
@@ -0,0 +1,11 @@
1
+ """Code to sync the local data from TheOldReader."""
2
+
3
+ ##############################################################################
4
+ # Local imports.
5
+ from .sync import ToRSync
6
+
7
+ ##############################################################################
8
+ # Exports.
9
+ __all__ = ["ToRSync"]
10
+
11
+ ### __init__.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
+ last_grabbed = last_grabbed_data_at() or (
75
+ datetime.now() - timedelta(days=load_configuration().local_history)
76
+ )
77
+ new_grab = datetime.now(timezone.utc)
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
@@ -0,0 +1,53 @@
1
+ """Support code for OptionLists that want to find things."""
2
+
3
+ ##############################################################################
4
+ # Python imports.
5
+ from typing import Callable, Iterator, Literal
6
+
7
+ ##############################################################################
8
+ # Textual imports.
9
+ from textual.widgets import OptionList
10
+
11
+ ##############################################################################
12
+ type HighlightDirection = Literal["next", "previous"]
13
+ """Type of a unread search direction."""
14
+
15
+
16
+ ##############################################################################
17
+ def options_after_highlight[T](
18
+ option_list: OptionList,
19
+ options: list[T],
20
+ direction: HighlightDirection,
21
+ option_filter: Callable[[T], bool] | None = None,
22
+ ) -> Iterator[T]:
23
+ """Return a list of `OptionList` options after the highlight.
24
+
25
+ Args:
26
+ option_list: The `OptionList` to work against.
27
+ options: The options from the given option list.
28
+ direction: The direction to consider as 'after'.
29
+ option_filter: Optional filter to apply to the list.
30
+
31
+ Returns:
32
+ An iterator of options.
33
+
34
+ Notes:
35
+ If there is no highlight, we default at position 0.
36
+
37
+ The options are taken as a parameter, rather than just been pulled
38
+ out of `option_list`, so that you have a chance to `cast` the list
39
+ to the desired type, which in turn ensures that the return type
40
+ matches.
41
+ """
42
+ option_filter = option_filter or (lambda _: True)
43
+ highlight = option_list.highlighted or 0
44
+ options = list(reversed(options)) if direction == "previous" else options
45
+ highlight = (len(options) - highlight - 1) if direction == "previous" else highlight
46
+ return (
47
+ option
48
+ for option in [*options[highlight:], *options[0:highlight]][1:]
49
+ if option_filter(option)
50
+ )
51
+
52
+
53
+ ### _after_highlight.py ends here
@@ -15,9 +15,9 @@ from oldas import Article
15
15
  ##############################################################################
16
16
  # Textual imports.
17
17
  from textual.app import ComposeResult
18
- from textual.containers import Vertical, VerticalScroll
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,18 +32,36 @@ 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;
38
42
 
43
+ Horizontal {
44
+ height: auto;
45
+ }
46
+
39
47
  #title {
40
48
  color: $text-accent;
49
+ width: 1fr;
41
50
  }
42
51
  #published, #link {
43
52
  color: $text-muted;
44
53
  }
45
54
  }
46
55
 
56
+ &:focus-within {
57
+ #header, Rule {
58
+ background: $boost;
59
+ }
60
+ Rule {
61
+ color: $border;
62
+ }
63
+ }
64
+
47
65
  Markdown {
48
66
  padding: 0 1 0 1;
49
67
  }
@@ -62,16 +80,18 @@ class ArticleContent(Vertical):
62
80
 
63
81
  def compose(self) -> ComposeResult:
64
82
  """Compose the content of the widget."""
83
+ yield Rule()
65
84
  with Vertical(id="header"):
66
- yield Label(id="title", markup=False)
67
- yield Label(id="published")
85
+ with Horizontal():
86
+ yield Label(id="title", markup=False)
87
+ yield Label(id="published")
68
88
  yield Label(id="link", markup=False)
89
+ yield Rule()
69
90
  with VerticalScroll():
70
91
  yield Markdown()
71
92
 
72
- def _watch_article(self) -> None:
93
+ async def _watch_article(self) -> None:
73
94
  """React to the article being updated."""
74
- self.set_class(self.article is not None, "--has-article")
75
95
  if self.article is not None:
76
96
  self.query_one("#title", Label).update(self.article.title)
77
97
  self.query_one("#published", Label).update(str(self.article.published))
@@ -81,7 +101,9 @@ class ArticleContent(Vertical):
81
101
  else:
82
102
  link.visible = True
83
103
  link.update(self.article.html_url)
84
- self.query_one(Markdown).update(convert(self.article.summary.content))
104
+ await self.query_one(Markdown).update(convert(self.article.summary.content))
105
+ self.query_one(VerticalScroll).scroll_home(animate=False)
106
+ self.set_class(self.article is not None, "--has-article")
85
107
 
86
108
  def focus(self, scroll_visible: bool = True) -> Self:
87
109
  self.query_one(VerticalScroll).focus(scroll_visible)
@@ -1,9 +1,13 @@
1
1
  """Widget to show a list of articles."""
2
2
 
3
+ ##############################################################################
4
+ # Backward compatibility.
5
+ from __future__ import annotations
6
+
3
7
  ##############################################################################
4
8
  # Python imports.
5
9
  from dataclasses import dataclass
6
- from typing import Iterator, Literal, cast
10
+ from typing import cast
7
11
 
8
12
  ##############################################################################
9
13
  # OldAs imports.
@@ -26,6 +30,10 @@ from textual.widgets.option_list import Option
26
30
  # Textual enhanced imports.
27
31
  from textual_enhanced.widgets import EnhancedOptionList
28
32
 
33
+ ##############################################################################
34
+ # Local imports.
35
+ from ._after_highlight import HighlightDirection, options_after_highlight
36
+
29
37
 
30
38
  ##############################################################################
31
39
  class ArticleView(Option):
@@ -73,11 +81,6 @@ class ArticleView(Option):
73
81
  return self._article
74
82
 
75
83
 
76
- ##############################################################################
77
- UnreadSearchDirection = Literal["next", "previous"]
78
- """Type of a unread search direction."""
79
-
80
-
81
84
  ##############################################################################
82
85
  class ArticleList(EnhancedOptionList):
83
86
  """Widget for showing a list of articles."""
@@ -139,36 +142,7 @@ class ArticleList(EnhancedOptionList):
139
142
  assert isinstance(message.option, ArticleView)
140
143
  self.post_message(self.ViewArticle(message.option.article))
141
144
 
142
- def _unread_articles_after_highlight(
143
- self, direction: UnreadSearchDirection
144
- ) -> Iterator[ArticleView]:
145
- """Return a list of all articles after the highlight.
146
-
147
- Args:
148
- direction: The direction to search in.
149
-
150
- Returns:
151
- An iterator of `ArticleView` objects that show an unread article.
152
-
153
- Notes:
154
- If there is no highlight, we default at position 0.
155
- """
156
- highlight = self.highlighted or 0
157
- options = (
158
- list(reversed(self.options)) if direction == "previous" else self.options
159
- )
160
- highlight = (
161
- (len(options) - highlight - 1) if direction == "previous" else highlight
162
- )
163
- return (
164
- article_view
165
- for article_view in cast(
166
- list[ArticleView], [*options[highlight:], *options[0:highlight]]
167
- )[1:]
168
- if article_view.article.is_unread
169
- )
170
-
171
- def _highlight_unread(self, direction: UnreadSearchDirection) -> bool:
145
+ def _highlight_unread(self, direction: HighlightDirection) -> bool:
172
146
  """Highlight the next unread article, if there is one.
173
147
 
174
148
  Args:
@@ -178,7 +152,15 @@ class ArticleList(EnhancedOptionList):
178
152
  `True` if an unread article was found and highlighted, `False`
179
153
  if not.
180
154
  """
181
- if next_hit := next(self._unread_articles_after_highlight(direction), None):
155
+ if next_hit := next(
156
+ options_after_highlight(
157
+ self,
158
+ cast(list[ArticleView], self.options),
159
+ direction,
160
+ lambda article_view: article_view.article.is_unread,
161
+ ),
162
+ None,
163
+ ):
182
164
  if next_hit.id is not None:
183
165
  self.highlighted = self.get_option_index(next_hit.id)
184
166
  return True
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
  ##############################################################################
8
8
  # Python imports.
9
9
  from dataclasses import dataclass
10
+ from typing import cast
10
11
 
11
12
  ##############################################################################
12
13
  # OldAs imports.
@@ -34,6 +35,7 @@ from textual_enhanced.widgets import EnhancedOptionList
34
35
  ##############################################################################
35
36
  # Local imports.
36
37
  from ..data import LocalUnread, get_navigation_state, save_navigation_state
38
+ from ._after_highlight import HighlightDirection, options_after_highlight
37
39
 
38
40
 
39
41
  ##############################################################################
@@ -102,7 +104,7 @@ class SubscriptionView(Option):
102
104
  "",
103
105
  str(unread) if unread else "",
104
106
  )
105
- super().__init__(prompt)
107
+ super().__init__(prompt, id=subscription.id)
106
108
 
107
109
  @property
108
110
  def subscription(self) -> Subscription:
@@ -236,5 +238,38 @@ class Navigation(EnhancedOptionList):
236
238
  return selected.subscription
237
239
  raise ValueError("Unknown category")
238
240
 
241
+ def _highlight_unread(self, direction: HighlightDirection) -> bool:
242
+ """Highlight the next category with unread articles, if there is one.
243
+
244
+ Args:
245
+ direction: The direction to search.
246
+
247
+ Returns:
248
+ `True` if an unread category was found and highlighted, `False`
249
+ if not.
250
+ """
251
+ if next_hit := next(
252
+ options_after_highlight(
253
+ self,
254
+ cast(list[FolderView | SubscriptionView], self.options),
255
+ direction,
256
+ lambda category: bool(category.id and self.unread.get(category.id)),
257
+ ),
258
+ None,
259
+ ):
260
+ if next_hit.id is not None:
261
+ self.highlighted = self.get_option_index(next_hit.id)
262
+ return True
263
+ self.notify("No more folders or subscriptions with unread articles")
264
+ return False
265
+
266
+ def highlight_next_unread_category(self) -> None:
267
+ """Highlight the next unread category."""
268
+ self._highlight_unread("next")
269
+
270
+ def highlight_previous_unread_category(self) -> None:
271
+ """Highlight the previous unread category."""
272
+ self._highlight_unread("previous")
273
+
239
274
 
240
275
  ### navigation.py ends here
File without changes
File without changes
File without changes
File without changes