oldnews 0.5.0__tar.gz → 0.6.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 (38) hide show
  1. {oldnews-0.5.0 → oldnews-0.6.0}/PKG-INFO +1 -1
  2. {oldnews-0.5.0 → oldnews-0.6.0}/pyproject.toml +1 -1
  3. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/__main__.py +11 -0
  4. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/data/__init__.py +4 -0
  5. oldnews-0.6.0/src/oldnews/data/dump.py +75 -0
  6. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/data/local_articles.py +15 -5
  7. oldnews-0.6.0/src/oldnews/data/log.py +38 -0
  8. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/screens/login.py +7 -3
  9. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/screens/main.py +14 -48
  10. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/screens/new_subscription.py +11 -5
  11. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/sync.py +129 -56
  12. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/widgets/article_content.py +20 -9
  13. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/widgets/navigation.py +19 -1
  14. {oldnews-0.5.0 → oldnews-0.6.0}/README.md +0 -0
  15. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/__init__.py +0 -0
  16. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/commands/__init__.py +0 -0
  17. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/commands/main.py +0 -0
  18. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/data/auth.py +0 -0
  19. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/data/config.py +0 -0
  20. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/data/db.py +0 -0
  21. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/data/last_grab.py +0 -0
  22. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/data/local_folders.py +0 -0
  23. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/data/local_subscriptions.py +0 -0
  24. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/data/local_unread.py +0 -0
  25. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/data/locations.py +0 -0
  26. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/data/navigation_state.py +0 -0
  27. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/data/reset.py +0 -0
  28. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/data/tools.py +0 -0
  29. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/oldnews.py +0 -0
  30. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/providers/__init__.py +0 -0
  31. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/providers/main.py +14 -14
  32. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/py.typed +0 -0
  33. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/screens/__init__.py +0 -0
  34. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/screens/folder_input.py +0 -0
  35. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/screens/information_display.py +0 -0
  36. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/widgets/__init__.py +0 -0
  37. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/widgets/_after_highlight.py +0 -0
  38. {oldnews-0.5.0 → oldnews-0.6.0}/src/oldnews/widgets/article_list.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oldnews
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oldnews"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "A terminal-based client for TheOldReader"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -10,6 +10,7 @@ from operator import attrgetter
10
10
  # Local imports.
11
11
  from . import __doc__, __version__
12
12
  from .data import initialise_database, reset_data
13
+ from .data.locations import config_dir, data_dir
13
14
  from .oldnews import OldNews
14
15
 
15
16
 
@@ -49,6 +50,13 @@ def get_args() -> Namespace:
49
50
  dest="command", help="Available commands", required=False
50
51
  )
51
52
 
53
+ # Add the 'directories' command.
54
+ sub_parser.add_parser(
55
+ "directories",
56
+ aliases=["dirs", "d"],
57
+ help="Show the directories created and used by OldNews",
58
+ )
59
+
52
60
  # Add the 'license' command.
53
61
  sub_parser.add_parser(
54
62
  "license",
@@ -135,6 +143,9 @@ def reset_news(args: Namespace) -> None:
135
143
  def main() -> None:
136
144
  """Main entry function."""
137
145
  match (args := get_args()).command:
146
+ case "d" | "dirs" | "directories":
147
+ print(config_dir())
148
+ print(data_dir())
138
149
  case "reset":
139
150
  reset_news(args)
140
151
  case "license" | "licence":
@@ -10,6 +10,7 @@ from .config import (
10
10
  update_configuration,
11
11
  )
12
12
  from .db import initialise_database
13
+ from .dump import data_dump
13
14
  from .last_grab import last_grabbed_data_at, remember_we_last_grabbed_at
14
15
  from .local_articles import (
15
16
  clean_old_read_articles,
@@ -26,6 +27,7 @@ from .local_articles import (
26
27
  from .local_folders import get_local_folders, save_local_folders
27
28
  from .local_subscriptions import get_local_subscriptions, save_local_subscriptions
28
29
  from .local_unread import LocalUnread, get_local_unread, total_unread
30
+ from .log import Log
29
31
  from .navigation_state import get_navigation_state, save_navigation_state
30
32
  from .reset import reset_data
31
33
 
@@ -33,7 +35,9 @@ from .reset import reset_data
33
35
  # Exports.
34
36
  __all__ = [
35
37
  "Configuration",
38
+ "Log",
36
39
  "clean_old_read_articles",
40
+ "data_dump",
37
41
  "get_auth_token",
38
42
  "get_local_articles",
39
43
  "get_local_folders",
@@ -0,0 +1,75 @@
1
+ """Provides tools to dump data into an easy-to-browse format."""
2
+
3
+ ##############################################################################
4
+ # Python imports.
5
+ from functools import singledispatch
6
+ from typing import Any
7
+
8
+ ##############################################################################
9
+ # OldAS imports.
10
+ from oldas import Article, Folder, Subscription
11
+
12
+ ##############################################################################
13
+ type DataDump = tuple[tuple[str, str], ...]
14
+ """Type of a data dump."""
15
+
16
+
17
+ ##############################################################################
18
+ @singledispatch
19
+ def data_dump(data: Any) -> DataDump:
20
+ """Dump the given data into an easy-to-browse format.
21
+
22
+ Args:
23
+ data: The data to dump.
24
+
25
+ Returns:
26
+ A `DataDump` of the data.
27
+ """
28
+ return (("Data", str(data)),)
29
+
30
+
31
+ ##############################################################################
32
+ @data_dump.register
33
+ def _(data: Folder) -> DataDump:
34
+ return (("ID", data.id), ("Sort ID", data.sort_id))
35
+
36
+
37
+ ##############################################################################
38
+ @data_dump.register
39
+ def _(data: Subscription) -> DataDump:
40
+ return (
41
+ ("ID", data.id),
42
+ ("Title", data.title),
43
+ ("Sort ID", data.sort_id),
44
+ ("First Item Time", f"{data.first_item_time}"),
45
+ ("URL", data.url),
46
+ ("HTML URL", data.html_url),
47
+ *(
48
+ (
49
+ f"Category[{n}]",
50
+ f"{category.id}, {category.label}",
51
+ )
52
+ for n, category in enumerate(data.categories)
53
+ ),
54
+ )
55
+
56
+
57
+ ##############################################################################
58
+ @data_dump.register
59
+ def _(data: Article) -> DataDump:
60
+ # TODO: The article has pretty rich data, so in here I'm not showing
61
+ # it all, just enough to be useful. In the future perhaps make it a
62
+ # lot richer.
63
+ return (
64
+ ("ID", data.id),
65
+ ("Title", data.title),
66
+ ("Published", f"{data.published}"),
67
+ ("Updated", f"{data.updated}"),
68
+ *(
69
+ (f"Category[{n}]", f"{category}")
70
+ for n, category in enumerate(data.categories)
71
+ ),
72
+ )
73
+
74
+
75
+ ### dump.py ends here
@@ -17,6 +17,7 @@ from typedal import TypedField, TypedTable, relationship
17
17
 
18
18
  ##############################################################################
19
19
  # Local imports.
20
+ from .log import Log
20
21
  from .tools import commit
21
22
 
22
23
 
@@ -339,11 +340,13 @@ def clean_old_read_articles(cutoff: timedelta) -> int:
339
340
  """Clean up articles that are older than the given cutoff time."""
340
341
  read = get_local_read_article_ids()
341
342
  retire_time = datetime.now() - cutoff
343
+ Log().debug(f"Cleaning up read articles published before {retire_time}")
342
344
  cleaned = len(
343
345
  LocalArticle.where(
344
346
  (LocalArticle.published < retire_time) & LocalArticle.id.belongs(read)
345
347
  ).delete()
346
348
  )
349
+ Log().debug(f"Cleaned: {cleaned}")
347
350
  commit(LocalArticle)
348
351
  return cleaned
349
352
 
@@ -358,6 +361,7 @@ def rename_folder_for_articles(rename_from: str | Folder, rename_to: str) -> Non
358
361
  """
359
362
  rename_from = Folders.full_id(rename_from)
360
363
  rename_to = Folders.full_id(rename_to)
364
+ Log().debug(f"Renaming folder for local articles from {rename_from} to {rename_to}")
361
365
  LocalArticleCategory.where(LocalArticleCategory.category == rename_from).update(
362
366
  category=rename_to
363
367
  )
@@ -371,9 +375,9 @@ def remove_folder_from_articles(folder: str | Folder) -> None:
371
375
  Args:
372
376
  folder: The folder to remove from all articles.
373
377
  """
374
- LocalArticleCategory.where(
375
- LocalArticleCategory.category == Folders.full_id(folder)
376
- ).delete()
378
+ folder = Folders.full_id(folder)
379
+ Log().debug(f"Removing folder {folder} from all local articles")
380
+ LocalArticleCategory.where(LocalArticleCategory.category == folder).delete()
377
381
  commit(LocalArticleCategory)
378
382
 
379
383
 
@@ -394,6 +398,9 @@ def move_subscription_articles(
394
398
  Folders.full_id(from_folder) if from_folder is not None else from_folder
395
399
  )
396
400
  to_folder = Folders.full_id(to_folder) if to_folder is not None else to_folder
401
+ Log().debug(
402
+ f"Moving all articles of {subscription.title} ({subscription.id}) from folder {from_folder} to {to_folder}"
403
+ )
397
404
  for article in LocalArticle.where(origin_stream_id=subscription.id).join().select():
398
405
  if from_folder:
399
406
  LocalArticleCategory.where(
@@ -407,13 +414,16 @@ def move_subscription_articles(
407
414
 
408
415
 
409
416
  ##############################################################################
410
- def remove_subscription_articles(subscription: Subscription) -> None:
417
+ def remove_subscription_articles(subscription: str | Subscription) -> None:
411
418
  """Remove all the articles associated with the given subscription.
412
419
 
413
420
  Args:
414
421
  subscription: The subscription to remove the articles for.
415
422
  """
416
- LocalArticle.where(origin_stream_id=subscription.id).delete()
423
+ if isinstance(subscription, Subscription):
424
+ subscription = subscription.id
425
+ Log().debug(f"Removing all local articles for subscription {subscription}")
426
+ LocalArticle.where(origin_stream_id=subscription).delete()
417
427
  commit(LocalArticle)
418
428
 
419
429
 
@@ -0,0 +1,38 @@
1
+ """Provides the application's logger."""
2
+
3
+ ##############################################################################
4
+ # Python imports.
5
+ from functools import cache
6
+ from logging import DEBUG, INFO, Formatter, Logger, getLogger
7
+ from logging.handlers import RotatingFileHandler
8
+ from os import getenv
9
+
10
+ ##############################################################################
11
+ # Local imports.
12
+ from .locations import data_dir
13
+
14
+
15
+ ##############################################################################
16
+ def _build_logger() -> Logger:
17
+ """Build a logger for the application.
18
+
19
+ Returns:
20
+ A configured `Logger` object.
21
+ """
22
+ logger = getLogger("oldnews")
23
+ logger.setLevel(DEBUG if getenv("OLDNEWS_DEBUG") else INFO)
24
+ file_handler = RotatingFileHandler(
25
+ data_dir() / "oldnews.log", maxBytes=1024 * 1024, backupCount=5
26
+ )
27
+ file_handler.setFormatter(
28
+ Formatter("%(asctime)s - %(levelname)s - %(module)s:%(lineno)d - %(message)s")
29
+ )
30
+ logger.addHandler(file_handler)
31
+ return logger
32
+
33
+
34
+ ##############################################################################
35
+ Log = cache(_build_logger)
36
+ """The application-wide logging object."""
37
+
38
+ ### log.py ends here
@@ -9,6 +9,7 @@ from oldas import OldASError, OldASInvalidLogin, Session
9
9
  from textual import on
10
10
  from textual.app import ComposeResult
11
11
  from textual.containers import Horizontal, Vertical
12
+ from textual.getters import query_one
12
13
  from textual.screen import ModalScreen
13
14
  from textual.widgets import Button, Input
14
15
 
@@ -48,6 +49,11 @@ class Login(ModalScreen[Session | None]):
48
49
 
49
50
  BINDINGS = [("escape", "cancel")]
50
51
 
52
+ user_name = query_one("#user-name", Input)
53
+ """The user name input widget."""
54
+ password = query_one("#password", Input)
55
+ """The password input widget."""
56
+
51
57
  def __init__(self, session: Session) -> None:
52
58
  """Initialise the login dialog.
53
59
 
@@ -71,9 +77,7 @@ class Login(ModalScreen[Session | None]):
71
77
  @on(Button.Pressed, "#login")
72
78
  async def login(self) -> None:
73
79
  """Log into TheOldReader."""
74
- if (user := self.query_one("#user-name", Input).value) and (
75
- password := self.query_one("#password", Input).value
76
- ):
80
+ if (user := self.user_name.value) and (password := self.password.value):
77
81
  try:
78
82
  # TODO: Add some sort of busy effect as the login happens.
79
83
  await self._session.login(user, password)
@@ -62,6 +62,7 @@ from ..commands import (
62
62
  from ..data import (
63
63
  LocalUnread,
64
64
  clean_old_read_articles,
65
+ data_dump,
65
66
  get_local_articles,
66
67
  get_local_folders,
67
68
  get_local_subscriptions,
@@ -159,24 +160,24 @@ class Main(EnhancedScreen[None]):
159
160
  Quit,
160
161
  RefreshFromTheOldReader,
161
162
  # Everything else.
163
+ AddSubscription,
164
+ ChangeTheme,
165
+ Copy,
166
+ CopyArticleToClipboard,
167
+ CopyFeedToClipboard,
168
+ CopyHomePageToClipboard,
162
169
  Escape,
163
170
  Information,
164
171
  MarkAllRead,
172
+ MoveSubscription,
165
173
  Next,
166
174
  NextUnread,
167
- Previous,
168
- PreviousUnread,
169
175
  OpenArticle,
170
176
  OpenHomePage,
171
- ChangeTheme,
172
- CopyHomePageToClipboard,
173
- CopyFeedToClipboard,
174
- CopyArticleToClipboard,
175
- Copy,
176
- AddSubscription,
177
- Rename,
177
+ Previous,
178
+ PreviousUnread,
178
179
  Remove,
179
- MoveSubscription,
180
+ Rename,
180
181
  ]
181
182
 
182
183
  BINDINGS = Command.bindings(*COMMAND_MESSAGES)
@@ -767,48 +768,13 @@ class Main(EnhancedScreen[None]):
767
768
  @work
768
769
  async def action_information_command(self) -> None:
769
770
  """Show some information about the current item."""
770
- # TODO: The article has pretty rich data, so in here I'm not showing
771
- # it all, just enough to be useful. In the future perhaps make it a
772
- # lot richer.
773
771
  information: InformationDisplay | None = None
774
772
  if self.navigation.has_focus and (category := self.navigation.current_category):
775
- if isinstance(category, Folder):
776
- information = InformationDisplay(
777
- "Folder", (("ID", category.id), ("Sort ID", category.sort_id))
778
- )
779
- elif isinstance(category, Subscription):
780
- information = InformationDisplay(
781
- "Subscription",
782
- (
783
- ("ID", category.id),
784
- ("Title", category.title),
785
- ("Sort ID", category.sort_id),
786
- ("First Item Time", f"{category.first_item_time}"),
787
- ("URL", category.url),
788
- ("HTML URL", category.html_url),
789
- *(
790
- (
791
- f"Category[{n}]",
792
- f"{sub_category.id}, {sub_category.label}",
793
- )
794
- for n, sub_category in enumerate(category.categories)
795
- ),
796
- ),
797
- )
798
- elif self.article_view.has_focus_within and self.article:
799
773
  information = InformationDisplay(
800
- "Article",
801
- (
802
- ("ID", self.article.id),
803
- ("Title", self.article.title),
804
- ("Published", f"{self.article.published}"),
805
- ("Updated", f"{self.article.updated}"),
806
- *(
807
- (f"Category[{n}]", f"{sub_category}")
808
- for n, sub_category in enumerate(self.article.categories)
809
- ),
810
- ),
774
+ category.__class__.__name__, data_dump(category)
811
775
  )
776
+ elif self.article_view.has_focus_within and self.article:
777
+ information = InformationDisplay("Article", data_dump(self.article))
812
778
  if information:
813
779
  await self.app.push_screen_wait(information)
814
780
 
@@ -13,6 +13,7 @@ from oldas import Folders
13
13
  from textual import on
14
14
  from textual.app import ComposeResult
15
15
  from textual.containers import Horizontal, Vertical
16
+ from textual.getters import query_one
16
17
  from textual.screen import ModalScreen
17
18
  from textual.widgets import Button, Input, Label
18
19
 
@@ -66,6 +67,13 @@ class NewSubscription(ModalScreen[NewSubscriptionData | None]):
66
67
 
67
68
  BINDINGS = [("escape", "cancel")]
68
69
 
70
+ feed_input = query_one("#feed", Input)
71
+ """The feed input widget."""
72
+ folder_input = query_one("#folder", Input)
73
+ """The folder input widget."""
74
+ add_button = query_one("#add", Button)
75
+ """The add button."""
76
+
69
77
  def __init__(self, folders: Folders) -> None:
70
78
  """Initialise the dialog object.
71
79
 
@@ -98,18 +106,16 @@ class NewSubscription(ModalScreen[NewSubscriptionData | None]):
98
106
  @on(Input.Changed, "#feed")
99
107
  def _refresh_state(self) -> None:
100
108
  """Refresh the state of the dialog."""
101
- self.query_one("#add").disabled = not bool(
102
- self.query_one("#feed", Input).value.strip()
103
- )
109
+ self.add_button.disabled = not bool(self.feed_input.value.strip())
104
110
 
105
111
  @on(Button.Pressed, "#add")
106
112
  def action_add(self) -> None:
107
113
  """React to the user pressing the add button."""
108
- if feed := self.query_one("#feed", Input).value.strip():
114
+ if feed := self.feed_input.value.strip():
109
115
  self.dismiss(
110
116
  NewSubscriptionData(
111
117
  feed=feed,
112
- folder=self.query_one("#folder", Input).value.strip(),
118
+ folder=self.folder_input.value.strip(),
113
119
  )
114
120
  )
115
121
 
@@ -21,6 +21,7 @@ from oldas import (
21
21
  ##############################################################################
22
22
  from .data import (
23
23
  LocalUnread,
24
+ Log,
24
25
  get_local_subscriptions,
25
26
  get_local_unread,
26
27
  get_unread_article_ids,
@@ -28,6 +29,7 @@ from .data import (
28
29
  load_configuration,
29
30
  locally_mark_article_ids_read,
30
31
  remember_we_last_grabbed_at,
32
+ remove_subscription_articles,
31
33
  save_local_articles,
32
34
  save_local_folders,
33
35
  save_local_subscriptions,
@@ -60,12 +62,22 @@ class ToRSync:
60
62
  on_sync_finished: Callback = None
61
63
  """Function to call when the sync has finished."""
62
64
 
63
- def _step(self, step: str) -> None:
65
+ def __post_init__(self) -> None:
66
+ """Initialise the sync object."""
67
+ self._last_sync = last_grabbed_data_at()
68
+ """The time at which we last did a sync."""
69
+ self._first_sync = self._last_sync is None
70
+ """Is this our first ever sync?"""
71
+
72
+ def _step(self, step: str, *, log: bool = True) -> None:
64
73
  """Mark a new step.
65
74
 
66
75
  Args:
67
76
  step: The step that is happening.
77
+ log: Should we log the step?
68
78
  """
79
+ if log:
80
+ Log().info(step)
69
81
  if self.on_new_step:
70
82
  self.on_new_step(step)
71
83
 
@@ -75,6 +87,7 @@ class ToRSync:
75
87
  Args:
76
88
  result: The result that should be shown.
77
89
  """
90
+ Log().info(result)
78
91
  if self.on_new_result:
79
92
  self.on_new_result(result)
80
93
 
@@ -103,34 +116,21 @@ class ToRSync:
103
116
  save_local_articles(Articles([article]))
104
117
  loaded += 1
105
118
  if (loaded % 10) == 0:
106
- self._step(f"{description}: {loaded}")
119
+ self._step(f"{description}: {loaded}", log=False)
107
120
  return loaded
108
121
 
109
- async def _download_newest_articles(self) -> None:
110
- """Download the latest articles available."""
111
- new_grab = datetime.now(timezone.utc)
112
- last_grabbed = last_grabbed_data_at() or (
113
- new_grab - timedelta(days=load_configuration().local_history)
114
- )
115
- if loaded := await self._download(
116
- Articles.stream_new_since(self.session, last_grabbed, n=10),
117
- "Downloading articles from TheOldReader",
118
- ):
119
- self._result(f"Articles downloaded: {loaded}")
120
- else:
121
- self._result("No new articles found on TheOldReader")
122
- remember_we_last_grabbed_at(new_grab)
123
-
124
- async def _refresh_read_status(self) -> None:
122
+ async def _get_updated_read_status(self) -> None:
125
123
  """Refresh the read status from the server."""
126
- self._step("Getting list of unread articles from TheOldReader")
124
+ if self._first_sync:
125
+ return
126
+ self._step("Syncing read/unread status with TheOldReader")
127
127
  remote_unread_articles = set(
128
128
  article_id.full_id
129
129
  for article_id in await ArticleIDs.load_unread(self.session)
130
130
  )
131
- self._step("Comparing against locally-read articles")
132
131
  local_unread_articles = set(get_unread_article_ids())
133
132
  if mark_as_read := local_unread_articles - remote_unread_articles:
133
+ Log().debug(f"Articles found as marked read elsewhere: {mark_as_read}")
134
134
  locally_mark_article_ids_read(mark_as_read)
135
135
  self._result(
136
136
  f"Articles found read elsewhere on TheOldReader: {len(mark_as_read)}"
@@ -154,61 +154,134 @@ class ToRSync:
154
154
  f"Downloaded article backlog for {subscription.title}: {loaded}"
155
155
  )
156
156
 
157
- async def refresh(self) -> None:
158
- """Refresh the data from TheOldReader.
157
+ async def _get_folders(self) -> Folders:
158
+ """Get the list of folders from the server.
159
159
 
160
- Args:
161
- session: The TheOldReader API session object.
160
+ Returns:
161
+ The folders.
162
162
  """
163
-
164
- # Get the folder list.
165
163
  self._step("Getting folder list")
166
164
  folders = save_local_folders(await Folders.load(self.session))
167
165
  if self.on_new_folders:
168
166
  self.on_new_folders(folders)
167
+ return folders
169
168
 
170
- # Get the subscriptions list.
171
- exising_subscriptions = get_local_subscriptions()
169
+ async def _get_subscriptions(self) -> tuple[Subscriptions, Subscriptions]:
170
+ """Get the list of subscriptions from the server.
171
+
172
+ Returns:
173
+ A tuple of the original subscription list before the sync, and
174
+ after.
175
+ """
172
176
  self._step("Getting subscriptions list")
177
+ original_subscriptions = get_local_subscriptions()
173
178
  subscriptions = save_local_subscriptions(await Subscriptions.load(self.session))
174
179
  if self.on_new_subscriptions:
175
180
  self.on_new_subscriptions(subscriptions)
181
+ return original_subscriptions, subscriptions
176
182
 
177
- # Download the latest articles we don't know about.
178
- if grabbed_before := ((last_grab := last_grabbed_data_at()) is not None):
179
- self._step("Getting available articles")
183
+ async def _get_new_articles(self) -> None:
184
+ """Download any new articles."""
185
+ self._step(
186
+ "Getting available articles"
187
+ if self._first_sync
188
+ else f"Getting new articles since {self._last_sync}"
189
+ )
190
+ new_grab = datetime.now(timezone.utc)
191
+ last_grabbed = self._last_sync or (
192
+ new_grab - timedelta(days=load_configuration().local_history)
193
+ )
194
+ if loaded := await self._download(
195
+ Articles.stream_new_since(self.session, last_grabbed, n=10),
196
+ "Downloading articles from TheOldReader",
197
+ ):
198
+ self._result(f"Articles downloaded: {loaded}")
180
199
  else:
181
- self._step(f"Getting new articles since {last_grab}")
182
- await self._download_newest_articles()
183
-
184
- # If we have grabbed data before, let's try and sync up what's been read.
185
- if grabbed_before:
186
- await self._refresh_read_status()
187
-
188
- # It's possible we have subscriptions we didn't know about before,
189
- # so we want to go and backfill their content regardless of read or
190
- # unread status. So, if it looks like we've grabbed data before but
191
- # now we have subscriptions we didn't know about before... let's
192
- # grab their history regardless.
193
- if grabbed_before:
194
- was_subscribed_to = set(
195
- subscription.id for subscription in exising_subscriptions
200
+ self._result("No new articles found on TheOldReader")
201
+ remember_we_last_grabbed_at(new_grab)
202
+
203
+ @staticmethod
204
+ def _set_of_ids(subscriptions: Subscriptions) -> set[str]:
205
+ """Get a set of the IDs of the given subscriptions.
206
+
207
+ Args:
208
+ subscriptions: The subscriptions to get the IDs of.
209
+
210
+ Returns:
211
+ A [set][`set`] of subscription IDs.
212
+ """
213
+ return {subscription.id for subscription in subscriptions}
214
+
215
+ async def _get_historical_articles(
216
+ self,
217
+ original_subscriptions: Subscriptions,
218
+ current_subscriptions: Subscriptions,
219
+ ) -> None:
220
+ """Download article histories for any new subscriptions.
221
+
222
+ Args:
223
+ original_subscriptions: The known subscriptions before the sync.
224
+ current_subscriptions: The subscriptions we're now subscribed to.
225
+
226
+ It's possible we have subscriptions we didn't know about before, so
227
+ we want to go and backfill their content regardless of read or
228
+ unread status. So, if it looks like we've grabbed data before but
229
+ now we have subscriptions we didn't know about before... let's grab
230
+ their history regardless.
231
+ """
232
+ Log().info("Checking for historical articles")
233
+ if not self._first_sync and (
234
+ new_subscriptions := self._set_of_ids(current_subscriptions)
235
+ - self._set_of_ids(original_subscriptions)
236
+ ):
237
+ Log().info(f"New subscriptions found: {new_subscriptions}")
238
+ await self._download_backlog(
239
+ subscription
240
+ for subscription in current_subscriptions
241
+ if subscription.id in new_subscriptions
196
242
  )
197
- now_subscribed_to = set(subscription.id for subscription in subscriptions)
198
- if new_subscriptions := now_subscribed_to - was_subscribed_to:
199
- await self._download_backlog(
200
- subscription
201
- for subscription in subscriptions
202
- if subscription.id in new_subscriptions
203
- )
204
243
 
205
- # Recalculate the unread counts.
206
- self._step("Calculating unread counts")
244
+ def _clean_orphaned_articles(
245
+ self,
246
+ original_subscriptions: Subscriptions,
247
+ current_subscriptions: Subscriptions,
248
+ ) -> None:
249
+ """Clean any articles left over from removed subscriptions.
250
+
251
+ Args:
252
+ original_subscriptions: The known subscriptions before the sync.
253
+ current_subscriptions: The subscriptions we're now subscribed to.
254
+ """
255
+ Log().info("Checking for orphaned articles")
256
+ if not self._first_sync and (
257
+ removed_subscriptions := self._set_of_ids(original_subscriptions)
258
+ - self._set_of_ids(current_subscriptions)
259
+ ):
260
+ Log().info(f"Found remotely-removed subscriptions: {removed_subscriptions}")
261
+ for subscription in removed_subscriptions:
262
+ remove_subscription_articles(subscription)
263
+
264
+ def _get_unread_counts(
265
+ self, folders: Folders, subscriptions: Subscriptions
266
+ ) -> None:
267
+ """Get the updated unread counts."""
207
268
  unread = get_local_unread(folders, subscriptions)
208
269
  if self.on_new_unread:
209
270
  self.on_new_unread(unread)
210
271
 
211
- # Finally we're all done.
272
+ async def refresh(self) -> None:
273
+ """Refresh the data from TheOldReader.
274
+
275
+ Args:
276
+ session: The TheOldReader API session object.
277
+ """
278
+ folders = await self._get_folders()
279
+ original_subscriptions, subscriptions = await self._get_subscriptions()
280
+ await self._get_new_articles()
281
+ await self._get_updated_read_status()
282
+ await self._get_historical_articles(original_subscriptions, subscriptions)
283
+ self._clean_orphaned_articles(original_subscriptions, subscriptions)
284
+ self._get_unread_counts(folders, subscriptions)
212
285
  if self.on_sync_finished:
213
286
  self.on_sync_finished()
214
287
 
@@ -16,6 +16,7 @@ from oldas import Article
16
16
  # Textual imports.
17
17
  from textual.app import ComposeResult
18
18
  from textual.containers import Horizontal, Vertical, VerticalScroll
19
+ from textual.getters import query_one
19
20
  from textual.reactive import var
20
21
  from textual.widgets import Label, Markdown, Rule
21
22
 
@@ -78,6 +79,17 @@ class ArticleContent(Vertical):
78
79
  article: var[Article | None] = var(None)
79
80
  """The article being viewed."""
80
81
 
82
+ title = query_one("#title", Label)
83
+ """The title label."""
84
+ published = query_one("#published", Label)
85
+ """The published date label."""
86
+ link = query_one("#link", Label)
87
+ """The link label."""
88
+ markdown = query_one(Markdown)
89
+ """The markdown display for the article."""
90
+ content = query_one(VerticalScroll)
91
+ """The article content."""
92
+
81
93
  def compose(self) -> ComposeResult:
82
94
  """Compose the content of the widget."""
83
95
  yield Rule()
@@ -93,20 +105,19 @@ class ArticleContent(Vertical):
93
105
  async def _watch_article(self) -> None:
94
106
  """React to the article being updated."""
95
107
  if self.article is not None:
96
- self.query_one("#title", Label).update(self.article.title)
97
- self.query_one("#published", Label).update(str(self.article.published))
98
- link = self.query_one("#link", Label)
108
+ self.title.update(self.article.title)
109
+ self.published.update(str(self.article.published))
99
110
  if self.article.html_url is None:
100
- link.visible = False
111
+ self.link.visible = False
101
112
  else:
102
- link.visible = True
103
- link.update(self.article.html_url)
104
- await self.query_one(Markdown).update(convert(self.article.summary.content))
105
- self.query_one(VerticalScroll).scroll_home(animate=False)
113
+ self.link.visible = True
114
+ self.link.update(self.article.html_url)
115
+ await self.markdown.update(convert(self.article.summary.content))
116
+ self.content.scroll_home(animate=False)
106
117
  self.set_class(self.article is not None, "--has-article")
107
118
 
108
119
  def focus(self, scroll_visible: bool = True) -> Self:
109
- self.query_one(VerticalScroll).focus(scroll_visible)
120
+ self.content.focus(scroll_visible)
110
121
  return self
111
122
 
112
123
 
@@ -123,7 +123,13 @@ class Navigation(EnhancedOptionList):
123
123
  """
124
124
 
125
125
  BINDINGS = [
126
- HelpfulBinding("ctrl+enter", "toggle_folder", tooltip="Expand/collapse folder")
126
+ HelpfulBinding("ctrl+enter", "toggle_folder", tooltip="Expand/collapse folder"),
127
+ HelpfulBinding(
128
+ "ctrl+right_square_bracket", "expand_all", tooltip="Expand all folders"
129
+ ),
130
+ HelpfulBinding(
131
+ "ctrl+left_square_bracket", "collapse_all", tooltip="Collapse all folders"
132
+ ),
127
133
  ]
128
134
 
129
135
  folders: var[Folders] = var(Folders)
@@ -213,6 +219,18 @@ class Navigation(EnhancedOptionList):
213
219
  self._save_state()
214
220
  self._refresh_navigation()
215
221
 
222
+ def _action_expand_all(self) -> None:
223
+ """Action that expands all folders."""
224
+ self._expanded = {folder.id for folder in self.folders}
225
+ self._save_state()
226
+ self._refresh_navigation()
227
+
228
+ def _action_collapse_all(self) -> None:
229
+ """Action that collapses all folders."""
230
+ self._expanded = set()
231
+ self._save_state()
232
+ self._refresh_navigation()
233
+
216
234
  @work(thread=True)
217
235
  def _save_state(self) -> None:
218
236
  """Save the folder expanded/collapsed state."""
File without changes
File without changes
File without changes
File without changes
@@ -45,28 +45,28 @@ class MainCommands(CommandsProvider):
45
45
  Yields:
46
46
  The commands for the command palette.
47
47
  """
48
+ yield AddSubscription()
49
+ yield ChangeTheme()
50
+ yield from self.maybe(Copy)
51
+ yield from self.maybe(CopyArticleToClipboard)
52
+ yield from self.maybe(CopyFeedToClipboard)
53
+ yield from self.maybe(CopyHomePageToClipboard)
48
54
  yield Escape()
55
+ yield Help()
56
+ yield from self.maybe(Information)
57
+ yield from self.maybe(MarkAllRead)
58
+ yield from self.maybe(MoveSubscription)
49
59
  yield from self.maybe(Next)
50
60
  yield from self.maybe(NextUnread)
51
- yield from self.maybe(Previous)
52
- yield from self.maybe(PreviousUnread)
53
61
  yield from self.maybe(OpenArticle)
54
62
  yield from self.maybe(OpenHomePage)
55
- yield from self.maybe(MarkAllRead)
56
- yield from self.maybe(MoveSubscription)
57
- yield from self.maybe(CopyHomePageToClipboard)
58
- yield from self.maybe(CopyFeedToClipboard)
59
- yield from self.maybe(CopyArticleToClipboard)
60
- yield from self.maybe(Copy)
63
+ yield from self.maybe(Previous)
64
+ yield from self.maybe(PreviousUnread)
65
+ yield Quit()
66
+ yield RefreshFromTheOldReader()
61
67
  yield from self.maybe(Rename)
62
68
  yield from self.maybe(Remove)
63
- yield from self.maybe(Information)
64
- yield AddSubscription()
65
69
  yield ToggleShowAll()
66
- yield RefreshFromTheOldReader()
67
- yield ChangeTheme()
68
- yield Help()
69
- yield Quit()
70
70
 
71
71
 
72
72
  ### main.py ends here
File without changes