oldnews 0.7.0__py3-none-any.whl → 0.8.0__py3-none-any.whl

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.
oldnews/screens/main.py CHANGED
@@ -6,6 +6,10 @@ from dataclasses import dataclass
6
6
  from datetime import datetime, timedelta
7
7
  from webbrowser import open as open_url
8
8
 
9
+ ##############################################################################
10
+ # BagOfStuff imports.
11
+ from bagofstuff.pipe import Pipe
12
+
9
13
  ##############################################################################
10
14
  # OldAs imports.
11
15
  from oldas import (
@@ -79,11 +83,12 @@ from ..data import (
79
83
  update_configuration,
80
84
  )
81
85
  from ..providers import MainCommands
82
- from ..sync import ToRSync
86
+ from ..sync import TheOldReaderSync
83
87
  from ..widgets import ArticleContent, ArticleList, Navigation
84
88
  from .folder_input import FolderInput
85
89
  from .information_display import InformationDisplay
86
90
  from .new_subscription import NewSubscription
91
+ from .process_subscription import ProcessSubscription
87
92
 
88
93
 
89
94
  ##############################################################################
@@ -402,17 +407,19 @@ class Main(EnhancedScreen[None]):
402
407
  @work(exclusive=True)
403
408
  async def action_refresh_from_the_old_reader_command(self) -> None:
404
409
  """Load the main data from TheOldReader."""
405
- await ToRSync(
410
+ await TheOldReaderSync(
406
411
  self._session,
407
- on_new_step=lambda step: self.post_message(self.SubTitle(step)),
408
- on_new_result=lambda result: self.notify(result),
409
- on_new_folders=lambda folders: self.post_message(self.NewFolders(folders)),
410
- on_new_subscriptions=lambda subscriptions: self.post_message(
411
- self.NewSubscriptions(subscriptions)
412
+ on_new_step=Pipe[str, bool](self.SubTitle, self.post_message),
413
+ on_new_result=self.notify,
414
+ on_new_folders=Pipe[Folders, bool](self.NewFolders, self.post_message),
415
+ on_new_subscriptions=Pipe[Subscriptions, bool](
416
+ self.NewSubscriptions, self.post_message
417
+ ),
418
+ on_new_unread=Pipe[LocalUnread, bool](self.NewUnread, self.post_message),
419
+ on_sync_finished=Pipe[Pipe.Nullary, bool](
420
+ self.SyncFinished, self.post_message
412
421
  ),
413
- on_new_unread=lambda unread: self.post_message(self.NewUnread(unread)),
414
- on_sync_finished=lambda: self.post_message(self.SyncFinished()),
415
- ).refresh()
422
+ ).sync()
416
423
 
417
424
  @on(Navigation.CategorySelected)
418
425
  def _handle_navigaion_selection(self, message: Navigation.CategorySelected) -> None:
@@ -624,11 +631,10 @@ class Main(EnhancedScreen[None]):
624
631
  if subscription := await self.app.push_screen_wait(
625
632
  NewSubscription(self.folders)
626
633
  ):
627
- self.notify(
628
- subscription.feed, title="Subscription request sent to TheOldReader..."
629
- )
630
634
  if (
631
- result := await Subscriptions.add(self._session, subscription.feed)
635
+ result := await self.app.push_screen_wait(
636
+ ProcessSubscription(self._session, subscription)
637
+ )
632
638
  ).failed:
633
639
  self.notify(
634
640
  result.error or "TheOldReader did not give a reason",
@@ -0,0 +1,87 @@
1
+ """Provides a dialog for waiting for TheOldReader to add a subscription."""
2
+
3
+ ##############################################################################
4
+ # OldAS imports.
5
+ from oldas import Session, Subscriptions
6
+ from oldas.subscriptions import SubscribeResult
7
+
8
+ ##############################################################################
9
+ # Textual imports.
10
+ from textual import work
11
+ from textual.app import ComposeResult
12
+ from textual.containers import Center, Vertical
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import Label, LoadingIndicator
15
+
16
+ ##############################################################################
17
+ # Local data.
18
+ from .new_subscription import NewSubscriptionData
19
+
20
+
21
+ ##############################################################################
22
+ class ProcessSubscription(ModalScreen[SubscribeResult]):
23
+ """Dialog for waiting for TheOldReader to process a subscription."""
24
+
25
+ CSS = """
26
+ ProcessSubscription {
27
+ align: center middle;
28
+
29
+ &> Vertical {
30
+ padding: 1 2;
31
+ width: auto;
32
+ height: auto;
33
+ min-width: 60%;
34
+ max-width: 90%;
35
+ background: $panel;
36
+ border: panel $border;
37
+ LoadingIndicator {
38
+ margin-top: 1;
39
+ }
40
+ Center {
41
+ width: 100%;
42
+ }
43
+ }
44
+ }
45
+ """
46
+
47
+ def __init__(self, session: Session, new_subscription: NewSubscriptionData) -> None:
48
+ """Initialise the subscription processing dialog object.
49
+
50
+ Args:
51
+ session: The ToR API session object.
52
+ new_subscription: The new subscription.
53
+ """
54
+ self._session = session
55
+ """The API session object."""
56
+ self._feed = new_subscription.feed
57
+ """The feed to subscribe to."""
58
+ self._folder = new_subscription.folder
59
+ """The folder to place the subscription in."""
60
+ super().__init__()
61
+
62
+ def compose(self) -> ComposeResult:
63
+ """Compose the content of the screen."""
64
+ with Vertical() as dialog:
65
+ dialog.border_title = "Waiting for TheOldReader to add the feed..."
66
+ yield Label(
67
+ f"Subscribing to {self._feed}"
68
+ + (f" and adding it to {self._folder}" if self._folder else "")
69
+ + ".\n\n"
70
+ "TheOldReader can take a short while to scan and add a feed. Please wait...",
71
+ shrink=True,
72
+ markup=False,
73
+ )
74
+ with Center():
75
+ yield LoadingIndicator()
76
+
77
+ @work
78
+ async def _request_subscription(self) -> None:
79
+ """Process the request."""
80
+ self.dismiss(await Subscriptions.add(self._session, self._feed))
81
+
82
+ def on_mount(self) -> None:
83
+ """Start the work once the DOM is ready."""
84
+ self._request_subscription()
85
+
86
+
87
+ ### process_subscription.py ends here
oldnews/sync.py CHANGED
@@ -4,7 +4,7 @@
4
4
  # Python imports.
5
5
  from dataclasses import dataclass
6
6
  from datetime import datetime, timedelta, timezone
7
- from typing import Any, AsyncIterator, Callable, Iterable
7
+ from typing import Any, AsyncIterator, Callable, Final, Iterable
8
8
 
9
9
  ##############################################################################
10
10
  # OldAS imports.
@@ -41,10 +41,14 @@ type Callback = Callable[[], Any] | None
41
41
  type CallbackWith[T] = Callable[[T], Any] | None
42
42
  """Type of callback with a single argument."""
43
43
 
44
+ ##############################################################################
45
+ BATCH_SIZE: Final[int] = 10
46
+ """Batch size for downloading articles."""
47
+
44
48
 
45
49
  ##############################################################################
46
50
  @dataclass
47
- class ToRSync:
51
+ class TheOldReaderSync:
48
52
  """Class that handles syncing data from TheOldReader."""
49
53
 
50
54
  session: Session
@@ -111,11 +115,11 @@ class ToRSync:
111
115
  # TODO: Right now I'm saving articles one at a time; perhaps I
112
116
  # should save them in small batches? This would be simple enough
113
117
  # -- perhaps same them in batches the same size as the buffer
114
- # window I'm using right now (currently 10 articles per trip to
115
- # ToR).
118
+ # window I'm using right now (currently BATCH_SIZE articles per
119
+ # trip to ToR).
116
120
  save_local_articles(Articles([article]))
117
121
  loaded += 1
118
- if (loaded % 10) == 0:
122
+ if (loaded % BATCH_SIZE) == 0:
119
123
  self._step(f"{description}: {loaded}", log=False)
120
124
  return loaded
121
125
 
@@ -147,7 +151,9 @@ class ToRSync:
147
151
  )
148
152
  for subscription in subscriptions:
149
153
  if loaded := await self._download(
150
- Articles.stream_new_since(self.session, cutoff, subscription, n=10),
154
+ Articles.stream_new_since(
155
+ self.session, cutoff, subscription, n=BATCH_SIZE
156
+ ),
151
157
  f"Downloading article backlog for {subscription.title}",
152
158
  ):
153
159
  self._result(
@@ -192,7 +198,7 @@ class ToRSync:
192
198
  new_grab - timedelta(days=load_configuration().local_history)
193
199
  )
194
200
  if loaded := await self._download(
195
- Articles.stream_new_since(self.session, last_grabbed, n=10),
201
+ Articles.stream_new_since(self.session, last_grabbed, n=BATCH_SIZE),
196
202
  "Downloading articles from TheOldReader",
197
203
  ):
198
204
  self._result(f"Articles downloaded: {loaded}")
@@ -264,17 +270,17 @@ class ToRSync:
264
270
  def _get_unread_counts(
265
271
  self, folders: Folders, subscriptions: Subscriptions
266
272
  ) -> None:
267
- """Get the updated unread counts."""
268
- unread = get_local_unread(folders, subscriptions)
269
- if self.on_new_unread:
270
- self.on_new_unread(unread)
271
-
272
- async def refresh(self) -> None:
273
- """Refresh the data from TheOldReader.
273
+ """Get the updated unread counts.
274
274
 
275
275
  Args:
276
- session: The TheOldReader API session object.
276
+ folders: The folders to get the counts for.
277
+ subscriptions: The subscriptions to get the counts for.
277
278
  """
279
+ if self.on_new_unread:
280
+ self.on_new_unread(get_local_unread(folders, subscriptions))
281
+
282
+ async def sync(self) -> None:
283
+ """Sync the data from TheOldReader."""
278
284
  folders = await self._get_folders()
279
285
  original_subscriptions, subscriptions = await self._get_subscriptions()
280
286
  await self._get_new_articles()
@@ -0,0 +1,52 @@
1
+ """Support code for OptionLists that want to find things."""
2
+
3
+ ##############################################################################
4
+ # Python imports.
5
+ from typing import Callable
6
+
7
+ ##############################################################################
8
+ # BagOfStuff imports.
9
+ from bagofstuff.itertools import Direction, starting_at
10
+
11
+
12
+ ##############################################################################
13
+ def next_matching_option[T](
14
+ options: list[T],
15
+ current_highlight: int | None,
16
+ direction: Direction,
17
+ matching: Callable[[T], bool] | None = None,
18
+ ) -> T | None:
19
+ """Return a list of `OptionList` options after the highlight.
20
+
21
+ Args:
22
+ options: The options from the given option list.
23
+ current_highlight: The current highlighted option.
24
+ direction: The direction to work in.
25
+ matching: Optional filter to apply to the list.
26
+
27
+ Returns:
28
+ The next matching option, or `None` if there isn't one.
29
+
30
+ Notes:
31
+ If there is no highlight, we default at position 0.
32
+ """
33
+ matching = matching or (lambda _: True)
34
+ if current_highlight is None:
35
+ current_highlight = 0
36
+ else:
37
+ current_highlight += 1 if direction == "forward" else -1
38
+ return next(
39
+ (
40
+ option
41
+ for option in starting_at(options, current_highlight, direction)
42
+ if matching(option)
43
+ ),
44
+ None,
45
+ )
46
+
47
+
48
+ ##############################################################################
49
+ # Re-export the direction type.
50
+ __all__ = ["Direction"]
51
+
52
+ ### _next_matching_option.py ends here
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
  ##############################################################################
8
8
  # Python imports.
9
9
  from dataclasses import dataclass
10
+ from operator import attrgetter
10
11
  from typing import cast
11
12
 
12
13
  ##############################################################################
@@ -32,7 +33,7 @@ from textual_enhanced.widgets import EnhancedOptionList
32
33
 
33
34
  ##############################################################################
34
35
  # Local imports.
35
- from ._after_highlight import HighlightDirection, options_after_highlight
36
+ from ._next_matching_option import Direction, next_matching_option
36
37
 
37
38
 
38
39
  ##############################################################################
@@ -142,7 +143,7 @@ class ArticleList(EnhancedOptionList):
142
143
  assert isinstance(message.option, ArticleView)
143
144
  self.post_message(self.ViewArticle(message.option.article))
144
145
 
145
- def _highlight_unread(self, direction: HighlightDirection) -> bool:
146
+ def _highlight_unread(self, direction: Direction) -> bool:
146
147
  """Highlight the next unread article, if there is one.
147
148
 
148
149
  Args:
@@ -152,14 +153,11 @@ class ArticleList(EnhancedOptionList):
152
153
  `True` if an unread article was found and highlighted, `False`
153
154
  if not.
154
155
  """
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,
156
+ if next_hit := next_matching_option(
157
+ cast(list[ArticleView], self.options),
158
+ self.highlighted,
159
+ direction,
160
+ attrgetter("article.is_unread"),
163
161
  ):
164
162
  if next_hit.id is not None:
165
163
  self.highlighted = self.get_option_index(next_hit.id)
@@ -177,11 +175,11 @@ class ArticleList(EnhancedOptionList):
177
175
 
178
176
  def highlight_next_unread_article(self) -> None:
179
177
  """Highlight the next unread article in the list."""
180
- self._highlight_unread("next")
178
+ self._highlight_unread("forward")
181
179
 
182
180
  def highlight_previous_unread_article(self) -> None:
183
181
  """Highlight the previous unread article in the list."""
184
- self._highlight_unread("previous")
182
+ self._highlight_unread("backward")
185
183
 
186
184
  def select_next_article(self) -> None:
187
185
  """Select the next article in the list."""
@@ -195,12 +193,12 @@ class ArticleList(EnhancedOptionList):
195
193
 
196
194
  def select_next_unread_article(self) -> None:
197
195
  """Select the next unread article in the list."""
198
- if self._highlight_unread("next"):
196
+ if self._highlight_unread("forward"):
199
197
  self.call_later(self.run_action, "select")
200
198
 
201
199
  def select_previous_unread_article(self) -> None:
202
200
  """Select the next unread article in the list."""
203
- if self._highlight_unread("previous"):
201
+ if self._highlight_unread("backward"):
204
202
  self.call_later(self.run_action, "select")
205
203
 
206
204
 
@@ -35,7 +35,7 @@ from textual_enhanced.widgets import EnhancedOptionList
35
35
  ##############################################################################
36
36
  # Local imports.
37
37
  from ..data import LocalUnread, get_navigation_state, save_navigation_state
38
- from ._after_highlight import HighlightDirection, options_after_highlight
38
+ from ._next_matching_option import Direction, next_matching_option
39
39
 
40
40
 
41
41
  ##############################################################################
@@ -328,7 +328,18 @@ class Navigation(EnhancedOptionList):
328
328
  return current
329
329
  return None
330
330
 
331
- def _highlight_unread(self, direction: HighlightDirection) -> bool:
331
+ def _contains_unread(self, category: FolderView | SubscriptionView) -> bool:
332
+ """Does the given folder or subscription have unread items?
333
+
334
+ Args:
335
+ category: The folder or subscription to check.
336
+
337
+ Returns:
338
+ `True` if there are unread items, `False` if not.
339
+ """
340
+ return bool(category.id and self.unread.get(category.id))
341
+
342
+ def _highlight_unread(self, direction: Direction) -> bool:
332
343
  """Highlight the next category with unread articles, if there is one.
333
344
 
334
345
  Args:
@@ -338,14 +349,11 @@ class Navigation(EnhancedOptionList):
338
349
  `True` if an unread category was found and highlighted, `False`
339
350
  if not.
340
351
  """
341
- if next_hit := next(
342
- options_after_highlight(
343
- self,
344
- cast(list[FolderView | SubscriptionView], self.options),
345
- direction,
346
- lambda category: bool(category.id and self.unread.get(category.id)),
347
- ),
348
- None,
352
+ if next_hit := next_matching_option(
353
+ cast(list[FolderView | SubscriptionView], self.options),
354
+ self.highlighted,
355
+ direction,
356
+ self._contains_unread,
349
357
  ):
350
358
  if next_hit.id is not None:
351
359
  self.highlighted = self.get_option_index(next_hit.id)
@@ -355,11 +363,11 @@ class Navigation(EnhancedOptionList):
355
363
 
356
364
  def highlight_next_unread_category(self) -> None:
357
365
  """Highlight the next unread category."""
358
- self._highlight_unread("next")
366
+ self._highlight_unread("forward")
359
367
 
360
368
  def highlight_previous_unread_category(self) -> None:
361
369
  """Highlight the previous unread category."""
362
- self._highlight_unread("previous")
370
+ self._highlight_unread("backward")
363
371
 
364
372
 
365
373
  ### navigation.py ends here
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oldnews
3
- Version: 0.7.0
3
+ Version: 0.8.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
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.14
16
16
  Classifier: Typing :: Typed
17
+ Requires-Dist: bagofstuff>=0.1.0
17
18
  Requires-Dist: html-to-markdown>=2.15.0
18
19
  Requires-Dist: oldas>=0.10.0
19
20
  Requires-Dist: textual>=6.3.0
@@ -25,15 +25,16 @@ oldnews/screens/__init__.py,sha256=-Iif16T316dnZkgJjX1YE7e4v7FxI0k_V08qd5za0t8,3
25
25
  oldnews/screens/folder_input.py,sha256=i6KeRVD1Ucfg2T6VJmzdKKnUHf6qo_1ka0ZLlNMuabA,1869
26
26
  oldnews/screens/information_display.py,sha256=-qG1BJxnhF9HtaP8d_HIuRzwMlAwl5raCLIAFpIDODg,2543
27
27
  oldnews/screens/login.py,sha256=a2hUERxpKYg7GQIYlpyDgHTAUEKmsANjwHZx1r-gXSw,3377
28
- oldnews/screens/main.py,sha256=AuCL54UC-DHl-fyRTr1_lSS2hCBXQKizQirpWpLBazs,28618
28
+ oldnews/screens/main.py,sha256=lV62hmWbxn3Iq90gcGyEmX8X2zM7fQUvZVVo-8EcFHY,28766
29
29
  oldnews/screens/new_subscription.py,sha256=bk01RXWu3kvJbtm88o1mrYpll73Op3PWWl7ggIH29kk,4011
30
- oldnews/sync.py,sha256=OZv5JvImvzPAjV6cuCbiSoIeVy0TnfWAq-rL-Xs_ffk,10877
30
+ oldnews/screens/process_subscription.py,sha256=8Z7aJLx-KkItaCQXfgf44cOQ83AZPlRJcMxFDuaHk54,2891
31
+ oldnews/sync.py,sha256=iZbVniwhTTvZmLBGvwviG3wV-lyOBXEPQpm2xgiTeo8,11150
31
32
  oldnews/widgets/__init__.py,sha256=5VSjKswHxv2W8g0O-LWlUFS-gy7iQrIM8PGvSBhDNLU,438
32
- oldnews/widgets/_after_highlight.py,sha256=cUJXUXElKfDrTsmy2WzKqGcxu67mlTamRBSi-VM_ddQ,1903
33
+ oldnews/widgets/_next_matching_option.py,sha256=rKGyIcj_wUcGZEeQJ1QAJmVke1XRsDpNRy9GS7PZEYo,1600
33
34
  oldnews/widgets/article_content.py,sha256=pv8zobd3eTsALHYmP854dd_enqy32QXGoWXEILYnWfA,3553
34
- oldnews/widgets/article_list.py,sha256=dSmSGlk1X94ryy0bJ6ejc2WXO0XMvTWyTxEtR1cL_5k,7428
35
- oldnews/widgets/navigation.py,sha256=8GMjJtLFnvc9GJH7_ufChLsywPQno7HwWmBIrEnDjgo,12433
36
- oldnews-0.7.0.dist-info/WHEEL,sha256=e_m4S054HL0hyR3CpOk-b7Q7fDX6BuFkgL5OjAExXas,80
37
- oldnews-0.7.0.dist-info/entry_points.txt,sha256=FxY6Y4IsHZubhtdd0QJG3p2kCTcXe0e-Ib_AW0qkotE,51
38
- oldnews-0.7.0.dist-info/METADATA,sha256=BHH_29q28IZdWENsHZ7DpnqXBxoNDznqhJK2Vg-3JQk,2918
39
- oldnews-0.7.0.dist-info/RECORD,,
35
+ oldnews/widgets/article_list.py,sha256=586r4LLeQbNhamO4pEe0cj_nHDvctPmJTmTw4amHoEM,7372
36
+ oldnews/widgets/navigation.py,sha256=IWlET89rcXw99sNX2at1Zi76EKVADMfm-MzaG_XfCl4,12698
37
+ oldnews-0.8.0.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
38
+ oldnews-0.8.0.dist-info/entry_points.txt,sha256=FxY6Y4IsHZubhtdd0QJG3p2kCTcXe0e-Ib_AW0qkotE,51
39
+ oldnews-0.8.0.dist-info/METADATA,sha256=_9fxAFDTm7UlU8AHPV4wkRHdtIi-TTpS9pRkOwC5byQ,2951
40
+ oldnews-0.8.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.9.27
2
+ Generator: uv 0.9.28
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,53 +0,0 @@
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[:highlight]][1:]
49
- if option_filter(option)
50
- )
51
-
52
-
53
- ### _after_highlight.py ends here