oldnews 0.6.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 +20 -14
- oldnews/screens/process_subscription.py +87 -0
- oldnews/sync.py +21 -15
- oldnews/widgets/_next_matching_option.py +52 -0
- oldnews/widgets/article_list.py +12 -14
- oldnews/widgets/navigation.py +111 -50
- {oldnews-0.6.0.dist-info → oldnews-0.8.0.dist-info}/METADATA +3 -2
- {oldnews-0.6.0.dist-info → oldnews-0.8.0.dist-info}/RECORD +10 -9
- {oldnews-0.6.0.dist-info → oldnews-0.8.0.dist-info}/WHEEL +1 -1
- oldnews/widgets/_after_highlight.py +0 -53
- {oldnews-0.6.0.dist-info → oldnews-0.8.0.dist-info}/entry_points.txt +0 -0
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
|
|
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
|
|
410
|
+
await TheOldReaderSync(
|
|
406
411
|
self._session,
|
|
407
|
-
on_new_step=
|
|
408
|
-
on_new_result=
|
|
409
|
-
on_new_folders=
|
|
410
|
-
on_new_subscriptions=
|
|
411
|
-
self.NewSubscriptions
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 %
|
|
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(
|
|
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=
|
|
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
|
-
|
|
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
|
oldnews/widgets/article_list.py
CHANGED
|
@@ -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 .
|
|
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:
|
|
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 :=
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
201
|
+
if self._highlight_unread("backward"):
|
|
204
202
|
self.call_later(self.run_action, "select")
|
|
205
203
|
|
|
206
204
|
|
oldnews/widgets/navigation.py
CHANGED
|
@@ -7,7 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
##############################################################################
|
|
8
8
|
# Python imports.
|
|
9
9
|
from dataclasses import dataclass
|
|
10
|
-
from typing import cast
|
|
10
|
+
from typing import Callable, Iterable, Iterator, cast
|
|
11
11
|
|
|
12
12
|
##############################################################################
|
|
13
13
|
# OldAs imports.
|
|
@@ -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 .
|
|
38
|
+
from ._next_matching_option import Direction, next_matching_option
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
##############################################################################
|
|
@@ -157,41 +157,86 @@ class Navigation(EnhancedOptionList):
|
|
|
157
157
|
self._expanded = get_navigation_state()
|
|
158
158
|
"""The IDs of the folders that are expanded."""
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _key(attr: str) -> Callable[[object], str]:
|
|
162
|
+
"""Create a key to use with `sorted`.
|
|
162
163
|
|
|
163
164
|
Args:
|
|
164
|
-
|
|
165
|
+
attr: The attribute to sort on.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
A function to get a `casefold` version of the attribute.
|
|
165
169
|
"""
|
|
166
|
-
for subscription in self.subscriptions:
|
|
167
|
-
if any(
|
|
168
|
-
category.id == parent_folder for category in subscription.categories
|
|
169
|
-
):
|
|
170
|
-
self.add_option(SubscriptionView(subscription, self.unread))
|
|
171
170
|
|
|
172
|
-
|
|
173
|
-
|
|
171
|
+
def _casefold(value: object) -> str:
|
|
172
|
+
return cast(str, getattr(value, attr)).casefold()
|
|
173
|
+
|
|
174
|
+
return _casefold
|
|
175
|
+
|
|
176
|
+
def _viewable(
|
|
177
|
+
self, subscriptions: Iterable[Subscription]
|
|
178
|
+
) -> Iterator[SubscriptionView]:
|
|
179
|
+
"""Given a iterable of subscriptions, make them viewable.
|
|
174
180
|
|
|
175
181
|
Args:
|
|
176
|
-
|
|
182
|
+
subscriptions: The subscriptions to make viewable.
|
|
183
|
+
|
|
184
|
+
Yields:
|
|
185
|
+
Views of the subscriptions.
|
|
177
186
|
"""
|
|
178
|
-
|
|
179
|
-
|
|
187
|
+
yield from (
|
|
188
|
+
SubscriptionView(subscription, self.unread)
|
|
189
|
+
for subscription in sorted(subscriptions, key=self._key("title"))
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _gather_subscriptions_for_folder(
|
|
193
|
+
self, parent_folder: Folder
|
|
194
|
+
) -> Iterator[SubscriptionView]:
|
|
195
|
+
"""Gather the subscriptions for a given parent folder.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
parent_folder: The parent folder to add the subscriptions for.
|
|
199
|
+
|
|
200
|
+
Yields:
|
|
201
|
+
The subscriptions within that folder.
|
|
202
|
+
"""
|
|
203
|
+
yield from self._viewable(
|
|
204
|
+
subscription
|
|
205
|
+
for subscription in self.subscriptions
|
|
206
|
+
if parent_folder in subscription.categories
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _gather_folders(self) -> Iterator[FolderView | SubscriptionView]:
|
|
210
|
+
"""Gather up all the folders and their subscriptions.
|
|
211
|
+
|
|
212
|
+
Yields:
|
|
213
|
+
Folder and subscription options.
|
|
214
|
+
"""
|
|
215
|
+
for folder in sorted(self.folders, key=self._key("name")):
|
|
216
|
+
yield FolderView(
|
|
217
|
+
folder, expanded := folder.id in self._expanded, self.unread
|
|
218
|
+
)
|
|
219
|
+
if expanded:
|
|
220
|
+
yield from self._gather_subscriptions_for_folder(folder)
|
|
221
|
+
|
|
222
|
+
def _gather_folderless_subscrtiptions(self) -> Iterator[SubscriptionView]:
|
|
223
|
+
"""Gather up all the subscriptions that don't live in a folder.
|
|
224
|
+
|
|
225
|
+
Yields:
|
|
226
|
+
Subscription options for folderless subscriptions.
|
|
227
|
+
"""
|
|
228
|
+
yield from self._viewable(
|
|
229
|
+
subscription
|
|
230
|
+
for subscription in self.subscriptions
|
|
231
|
+
if not subscription.categories
|
|
180
232
|
)
|
|
181
|
-
if expanded:
|
|
182
|
-
self._add_subscriptions(folder.id)
|
|
183
233
|
|
|
184
234
|
def _refresh_navigation(self) -> None:
|
|
185
235
|
"""Refresh the content of the navigation widget."""
|
|
186
236
|
with self.preserved_highlight:
|
|
187
|
-
self.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if not subscription.categories:
|
|
191
|
-
self.add_option(SubscriptionView(subscription, self.unread))
|
|
192
|
-
# Now add all the subscriptions that are within folders.
|
|
193
|
-
for folder in self.folders:
|
|
194
|
-
self._add_folder(folder)
|
|
237
|
+
self.set_options(
|
|
238
|
+
(*self._gather_folderless_subscrtiptions(), *self._gather_folders())
|
|
239
|
+
)
|
|
195
240
|
|
|
196
241
|
def _watch_folders(self) -> None:
|
|
197
242
|
"""React to the folders being updated."""
|
|
@@ -205,6 +250,25 @@ class Navigation(EnhancedOptionList):
|
|
|
205
250
|
"""React to the unread data being updated."""
|
|
206
251
|
self._refresh_navigation()
|
|
207
252
|
|
|
253
|
+
@work(thread=True)
|
|
254
|
+
def _save_state(self, state: set[str]) -> None:
|
|
255
|
+
"""Save the folder expanded/collapsed state.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
state: The state to save.
|
|
259
|
+
"""
|
|
260
|
+
save_navigation_state(state)
|
|
261
|
+
|
|
262
|
+
def _set_expansion(self, new_state: set[str]) -> None:
|
|
263
|
+
"""Set the new navigation state.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
new_state: The new state to set.
|
|
267
|
+
"""
|
|
268
|
+
self._expanded = new_state
|
|
269
|
+
self._save_state(new_state)
|
|
270
|
+
self._refresh_navigation()
|
|
271
|
+
|
|
208
272
|
def _action_toggle_folder(self) -> None:
|
|
209
273
|
"""Action that toggles the expanded state of a folder."""
|
|
210
274
|
if self.highlighted is None:
|
|
@@ -215,26 +279,15 @@ class Navigation(EnhancedOptionList):
|
|
|
215
279
|
self.notify("Only folders can be collapsed/expanded", severity="warning")
|
|
216
280
|
return
|
|
217
281
|
if option.folder.id is not None:
|
|
218
|
-
self._expanded
|
|
219
|
-
self._save_state()
|
|
220
|
-
self._refresh_navigation()
|
|
282
|
+
self._set_expansion(self._expanded ^ {option.folder.id})
|
|
221
283
|
|
|
222
284
|
def _action_expand_all(self) -> None:
|
|
223
285
|
"""Action that expands all folders."""
|
|
224
|
-
self.
|
|
225
|
-
self._save_state()
|
|
226
|
-
self._refresh_navigation()
|
|
286
|
+
self._set_expansion({folder.id for folder in self.folders})
|
|
227
287
|
|
|
228
288
|
def _action_collapse_all(self) -> None:
|
|
229
289
|
"""Action that collapses all folders."""
|
|
230
|
-
self.
|
|
231
|
-
self._save_state()
|
|
232
|
-
self._refresh_navigation()
|
|
233
|
-
|
|
234
|
-
@work(thread=True)
|
|
235
|
-
def _save_state(self) -> None:
|
|
236
|
-
"""Save the folder expanded/collapsed state."""
|
|
237
|
-
save_navigation_state(self._expanded)
|
|
290
|
+
self._set_expansion(set())
|
|
238
291
|
|
|
239
292
|
@on(EnhancedOptionList.OptionSelected)
|
|
240
293
|
def _handle_selection(self, message: EnhancedOptionList.OptionSelected) -> None:
|
|
@@ -275,7 +328,18 @@ class Navigation(EnhancedOptionList):
|
|
|
275
328
|
return current
|
|
276
329
|
return None
|
|
277
330
|
|
|
278
|
-
def
|
|
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:
|
|
279
343
|
"""Highlight the next category with unread articles, if there is one.
|
|
280
344
|
|
|
281
345
|
Args:
|
|
@@ -285,14 +349,11 @@ class Navigation(EnhancedOptionList):
|
|
|
285
349
|
`True` if an unread category was found and highlighted, `False`
|
|
286
350
|
if not.
|
|
287
351
|
"""
|
|
288
|
-
if next_hit :=
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
lambda category: bool(category.id and self.unread.get(category.id)),
|
|
294
|
-
),
|
|
295
|
-
None,
|
|
352
|
+
if next_hit := next_matching_option(
|
|
353
|
+
cast(list[FolderView | SubscriptionView], self.options),
|
|
354
|
+
self.highlighted,
|
|
355
|
+
direction,
|
|
356
|
+
self._contains_unread,
|
|
296
357
|
):
|
|
297
358
|
if next_hit.id is not None:
|
|
298
359
|
self.highlighted = self.get_option_index(next_hit.id)
|
|
@@ -302,11 +363,11 @@ class Navigation(EnhancedOptionList):
|
|
|
302
363
|
|
|
303
364
|
def highlight_next_unread_category(self) -> None:
|
|
304
365
|
"""Highlight the next unread category."""
|
|
305
|
-
self._highlight_unread("
|
|
366
|
+
self._highlight_unread("forward")
|
|
306
367
|
|
|
307
368
|
def highlight_previous_unread_category(self) -> None:
|
|
308
369
|
"""Highlight the previous unread category."""
|
|
309
|
-
self._highlight_unread("
|
|
370
|
+
self._highlight_unread("backward")
|
|
310
371
|
|
|
311
372
|
|
|
312
373
|
### navigation.py ends here
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oldnews
|
|
3
|
-
Version: 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,8 +14,9 @@ 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
|
-
Requires-Dist: oldas>=0.
|
|
19
|
+
Requires-Dist: oldas>=0.10.0
|
|
19
20
|
Requires-Dist: textual>=6.3.0
|
|
20
21
|
Requires-Dist: textual-autocomplete>=4.0.6
|
|
21
22
|
Requires-Dist: textual-enhanced>=1.2.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=
|
|
28
|
+
oldnews/screens/main.py,sha256=lV62hmWbxn3Iq90gcGyEmX8X2zM7fQUvZVVo-8EcFHY,28766
|
|
29
29
|
oldnews/screens/new_subscription.py,sha256=bk01RXWu3kvJbtm88o1mrYpll73Op3PWWl7ggIH29kk,4011
|
|
30
|
-
oldnews/
|
|
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/
|
|
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=
|
|
35
|
-
oldnews/widgets/navigation.py,sha256=
|
|
36
|
-
oldnews-0.
|
|
37
|
-
oldnews-0.
|
|
38
|
-
oldnews-0.
|
|
39
|
-
oldnews-0.
|
|
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,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[0:highlight]][1:]
|
|
49
|
-
if option_filter(option)
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
### _after_highlight.py ends here
|
|
File without changes
|