oldnews 0.1.0__tar.gz → 0.2.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.
- {oldnews-0.1.0 → oldnews-0.2.0}/PKG-INFO +2 -3
- {oldnews-0.1.0 → oldnews-0.2.0}/pyproject.toml +2 -3
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/commands/__init__.py +2 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/commands/main.py +7 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/__init__.py +2 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/db.py +15 -9
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/local_articles.py +18 -3
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/providers/main.py +2 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/screens/main.py +32 -3
- oldnews-0.2.0/src/oldnews/widgets/_after_highlight.py +53 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/widgets/article_content.py +13 -6
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/widgets/article_list.py +19 -37
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/widgets/navigation.py +36 -1
- {oldnews-0.1.0 → oldnews-0.2.0}/README.md +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/__init__.py +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/__main__.py +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/auth.py +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/config.py +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/last_grab.py +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/local_folders.py +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/local_subscriptions.py +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/local_unread.py +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/locations.py +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/navigation_state.py +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/oldnews.py +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/providers/__init__.py +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/py.typed +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/screens/__init__.py +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/screens/login.py +0 -0
- {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/widgets/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oldnews
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -10,7 +10,6 @@ Classifier: Development Status :: 3 - Alpha
|
|
|
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.
|
|
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.
|
|
3
|
+
version = "0.2.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.
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
10
|
license = "GPL-3.0-or-later"
|
|
11
11
|
keywords = [
|
|
12
12
|
"atom",
|
|
@@ -31,7 +31,6 @@ classifiers = [
|
|
|
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",
|
|
@@ -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,
|
|
@@ -27,6 +28,7 @@ from .navigation_state import get_navigation_state, save_navigation_state
|
|
|
27
28
|
# Exports.
|
|
28
29
|
__all__ = [
|
|
29
30
|
"Configuration",
|
|
31
|
+
"clean_old_read_articles",
|
|
30
32
|
"get_auth_token",
|
|
31
33
|
"get_local_articles",
|
|
32
34
|
"get_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(
|
|
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(
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
@@ -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()
|
|
@@ -44,6 +44,7 @@ from ..commands import (
|
|
|
44
44
|
Next,
|
|
45
45
|
NextUnread,
|
|
46
46
|
OpenArticle,
|
|
47
|
+
OpenOrigin,
|
|
47
48
|
Previous,
|
|
48
49
|
PreviousUnread,
|
|
49
50
|
RefreshFromTheOldReader,
|
|
@@ -51,6 +52,7 @@ from ..commands import (
|
|
|
51
52
|
)
|
|
52
53
|
from ..data import (
|
|
53
54
|
LocalUnread,
|
|
55
|
+
clean_old_read_articles,
|
|
54
56
|
get_local_articles,
|
|
55
57
|
get_local_folders,
|
|
56
58
|
get_local_subscriptions,
|
|
@@ -145,6 +147,7 @@ class Main(EnhancedScreen[None]):
|
|
|
145
147
|
Previous,
|
|
146
148
|
PreviousUnread,
|
|
147
149
|
OpenArticle,
|
|
150
|
+
OpenOrigin,
|
|
148
151
|
ChangeTheme,
|
|
149
152
|
]
|
|
150
153
|
|
|
@@ -236,7 +239,7 @@ class Main(EnhancedScreen[None]):
|
|
|
236
239
|
# but okay let's be defensive... (when I can come up with a nice
|
|
237
240
|
# little MRE I'll report it).
|
|
238
241
|
return True
|
|
239
|
-
if action
|
|
242
|
+
if action in (OpenArticle.action_name(), OpenOrigin.action_name()):
|
|
240
243
|
return self.article is not None
|
|
241
244
|
if action in (Next.action_name(), Previous.action_name()):
|
|
242
245
|
return self.articles is not None
|
|
@@ -245,6 +248,12 @@ class Main(EnhancedScreen[None]):
|
|
|
245
248
|
PreviousUnread.action_name(),
|
|
246
249
|
MarkAllRead.action_name(),
|
|
247
250
|
):
|
|
251
|
+
# If we're inside the navigation panel...
|
|
252
|
+
if self.query_one(Navigation).has_focus:
|
|
253
|
+
# ...we just care if there's anything unread somewhere.
|
|
254
|
+
return any(total for total in self.unread.values())
|
|
255
|
+
# Otherwise we care if we can see a current list of articles and
|
|
256
|
+
# if there's something unread amongst them.
|
|
248
257
|
return self.articles is not None and any(
|
|
249
258
|
article.is_unread for article in self.articles
|
|
250
259
|
)
|
|
@@ -296,6 +305,10 @@ class Main(EnhancedScreen[None]):
|
|
|
296
305
|
self.post_message(self.NewFolders(folders))
|
|
297
306
|
if subscriptions := get_local_subscriptions():
|
|
298
307
|
self.post_message(self.NewSubscriptions(subscriptions))
|
|
308
|
+
if cleaned := clean_old_read_articles(
|
|
309
|
+
timedelta(days=load_configuration().local_history)
|
|
310
|
+
):
|
|
311
|
+
self.notify(f"Old read articles cleaned from local storage: {cleaned}")
|
|
299
312
|
if unread := get_local_unread(folders, subscriptions):
|
|
300
313
|
self.post_message(self.NewUnread(unread))
|
|
301
314
|
# If we've never grabbed data from ToR before, or if it's been long enough...
|
|
@@ -487,14 +500,18 @@ class Main(EnhancedScreen[None]):
|
|
|
487
500
|
|
|
488
501
|
def action_next_unread_command(self) -> None:
|
|
489
502
|
"""Go to the next unread article in the currently-viewed category."""
|
|
490
|
-
if self.
|
|
503
|
+
if (navigation := self.query_one(Navigation)).has_focus:
|
|
504
|
+
navigation.highlight_next_unread_category()
|
|
505
|
+
elif self.article is None:
|
|
491
506
|
self.query_one(ArticleList).highlight_next_unread_article()
|
|
492
507
|
else:
|
|
493
508
|
self.query_one(ArticleList).select_next_unread_article()
|
|
494
509
|
|
|
495
510
|
def action_previous_unread_command(self) -> None:
|
|
496
511
|
"""Go to the previous unread article in the currently-viewed category"""
|
|
497
|
-
if self.
|
|
512
|
+
if (navigation := self.query_one(Navigation)).has_focus:
|
|
513
|
+
navigation.highlight_previous_unread_category()
|
|
514
|
+
elif self.article is None:
|
|
498
515
|
self.query_one(ArticleList).highlight_previous_unread_article()
|
|
499
516
|
else:
|
|
500
517
|
self.query_one(ArticleList).select_previous_unread_article()
|
|
@@ -546,5 +563,17 @@ class Main(EnhancedScreen[None]):
|
|
|
546
563
|
severity="error",
|
|
547
564
|
)
|
|
548
565
|
|
|
566
|
+
def action_open_origin_command(self) -> None:
|
|
567
|
+
"""Open the origin of the current article in the web browser."""
|
|
568
|
+
if self.article is not None:
|
|
569
|
+
if self.article.origin.html_url:
|
|
570
|
+
open_url(self.article.origin.html_url)
|
|
571
|
+
else:
|
|
572
|
+
self.notify(
|
|
573
|
+
"No URL available for the article's origin",
|
|
574
|
+
severity="error",
|
|
575
|
+
title="Can't visit",
|
|
576
|
+
)
|
|
577
|
+
|
|
549
578
|
|
|
550
579
|
### main.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,7 +15,7 @@ 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
20
|
from textual.widgets import Label, Markdown
|
|
21
21
|
|
|
@@ -36,8 +36,13 @@ class ArticleContent(Vertical):
|
|
|
36
36
|
height: auto;
|
|
37
37
|
padding: 0 1 0 1;
|
|
38
38
|
|
|
39
|
+
Horizontal {
|
|
40
|
+
height: auto;
|
|
41
|
+
}
|
|
42
|
+
|
|
39
43
|
#title {
|
|
40
44
|
color: $text-accent;
|
|
45
|
+
width: 1fr;
|
|
41
46
|
}
|
|
42
47
|
#published, #link {
|
|
43
48
|
color: $text-muted;
|
|
@@ -63,15 +68,15 @@ class ArticleContent(Vertical):
|
|
|
63
68
|
def compose(self) -> ComposeResult:
|
|
64
69
|
"""Compose the content of the widget."""
|
|
65
70
|
with Vertical(id="header"):
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
with Horizontal():
|
|
72
|
+
yield Label(id="title", markup=False)
|
|
73
|
+
yield Label(id="published")
|
|
68
74
|
yield Label(id="link", markup=False)
|
|
69
75
|
with VerticalScroll():
|
|
70
76
|
yield Markdown()
|
|
71
77
|
|
|
72
|
-
def _watch_article(self) -> None:
|
|
78
|
+
async def _watch_article(self) -> None:
|
|
73
79
|
"""React to the article being updated."""
|
|
74
|
-
self.set_class(self.article is not None, "--has-article")
|
|
75
80
|
if self.article is not None:
|
|
76
81
|
self.query_one("#title", Label).update(self.article.title)
|
|
77
82
|
self.query_one("#published", Label).update(str(self.article.published))
|
|
@@ -81,7 +86,9 @@ class ArticleContent(Vertical):
|
|
|
81
86
|
else:
|
|
82
87
|
link.visible = True
|
|
83
88
|
link.update(self.article.html_url)
|
|
84
|
-
self.query_one(Markdown).update(convert(self.article.summary.content))
|
|
89
|
+
await self.query_one(Markdown).update(convert(self.article.summary.content))
|
|
90
|
+
self.query_one(VerticalScroll).scroll_home()
|
|
91
|
+
self.set_class(self.article is not None, "--has-article")
|
|
85
92
|
|
|
86
93
|
def focus(self, scroll_visible: bool = True) -> Self:
|
|
87
94
|
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
|
|
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
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|