oldnews 0.0.2__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.0.2 → oldnews-0.2.0}/PKG-INFO +5 -6
- {oldnews-0.0.2 → oldnews-0.2.0}/pyproject.toml +2 -3
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/commands/__init__.py +8 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/commands/main.py +28 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/__init__.py +2 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/db.py +15 -9
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/local_articles.py +18 -3
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/providers/main.py +8 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/screens/main.py +103 -13
- oldnews-0.2.0/src/oldnews/widgets/_after_highlight.py +53 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/widgets/article_content.py +13 -6
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/widgets/article_list.py +41 -41
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/widgets/navigation.py +36 -1
- {oldnews-0.0.2 → oldnews-0.2.0}/README.md +0 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/__init__.py +0 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/__main__.py +0 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/auth.py +0 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/config.py +0 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/last_grab.py +0 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/local_folders.py +0 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/local_subscriptions.py +0 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/local_unread.py +0 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/locations.py +0 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/navigation_state.py +0 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/oldnews.py +0 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/providers/__init__.py +0 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/py.typed +0 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/screens/__init__.py +0 -0
- {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/screens/login.py +0 -0
- {oldnews-0.0.2 → 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.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,13 +20,13 @@ 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.
|
|
25
|
-
Project-URL: Discussions, https://github.com/davep/oldnews/discussions
|
|
26
|
-
Project-URL: Documentation, https://oldnews.davep.dev/
|
|
23
|
+
Requires-Python: >=3.12
|
|
27
24
|
Project-URL: Homepage, https://github.com/davep/oldnews
|
|
28
|
-
Project-URL: Issues, https://github.com/davep/oldnews/issues
|
|
29
25
|
Project-URL: Repository, https://github.com/davep/oldnews
|
|
26
|
+
Project-URL: Documentation, https://oldnews.davep.dev/
|
|
30
27
|
Project-URL: Source, https://github.com/davep/oldnews
|
|
28
|
+
Project-URL: Issues, https://github.com/davep/oldnews/issues
|
|
29
|
+
Project-URL: Discussions, https://github.com/davep/oldnews/discussions
|
|
31
30
|
Description-Content-Type: text/markdown
|
|
32
31
|
|
|
33
32
|
# OldNews - A TheOldReader client for the terminal
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "oldnews"
|
|
3
|
-
version = "0.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",
|
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
# Local imports.
|
|
5
5
|
from .main import (
|
|
6
6
|
Escape,
|
|
7
|
+
MarkAllRead,
|
|
8
|
+
Next,
|
|
7
9
|
NextUnread,
|
|
8
10
|
OpenArticle,
|
|
11
|
+
OpenOrigin,
|
|
12
|
+
Previous,
|
|
9
13
|
PreviousUnread,
|
|
10
14
|
RefreshFromTheOldReader,
|
|
11
15
|
ToggleShowAll,
|
|
@@ -15,8 +19,12 @@ from .main import (
|
|
|
15
19
|
# Exports.
|
|
16
20
|
__all__ = [
|
|
17
21
|
"Escape",
|
|
22
|
+
"MarkAllRead",
|
|
23
|
+
"Next",
|
|
18
24
|
"NextUnread",
|
|
19
25
|
"OpenArticle",
|
|
26
|
+
"OpenOrigin",
|
|
27
|
+
"Previous",
|
|
20
28
|
"PreviousUnread",
|
|
21
29
|
"RefreshFromTheOldReader",
|
|
22
30
|
"ToggleShowAll",
|
|
@@ -35,6 +35,13 @@ class NextUnread(Command):
|
|
|
35
35
|
BINDING_KEY = "n"
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
##############################################################################
|
|
39
|
+
class Next(Command):
|
|
40
|
+
"""Navigate to the next article regardless of read status"""
|
|
41
|
+
|
|
42
|
+
BINDING_KEY = "N"
|
|
43
|
+
|
|
44
|
+
|
|
38
45
|
##############################################################################
|
|
39
46
|
class PreviousUnread(Command):
|
|
40
47
|
"""Navigate to the previous unread article in the currently-selected category"""
|
|
@@ -42,6 +49,13 @@ class PreviousUnread(Command):
|
|
|
42
49
|
BINDING_KEY = "p"
|
|
43
50
|
|
|
44
51
|
|
|
52
|
+
##############################################################################
|
|
53
|
+
class Previous(Command):
|
|
54
|
+
"""Navigate to the next article regardless of read status"""
|
|
55
|
+
|
|
56
|
+
BINDING_KEY = "P"
|
|
57
|
+
|
|
58
|
+
|
|
45
59
|
##############################################################################
|
|
46
60
|
class OpenArticle(Command):
|
|
47
61
|
"""Open the current article in the web browser"""
|
|
@@ -49,4 +63,18 @@ class OpenArticle(Command):
|
|
|
49
63
|
BINDING_KEY = "o"
|
|
50
64
|
|
|
51
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
|
+
|
|
73
|
+
##############################################################################
|
|
74
|
+
class MarkAllRead(Command):
|
|
75
|
+
"""Mark all unread articles in the current category as read"""
|
|
76
|
+
|
|
77
|
+
BINDING_KEY = "R"
|
|
78
|
+
|
|
79
|
+
|
|
52
80
|
### main.py ends here
|
|
@@ -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
|
|
@@ -14,8 +14,12 @@ from textual_enhanced.commands import (
|
|
|
14
14
|
# Local imports.
|
|
15
15
|
from ..commands import (
|
|
16
16
|
Escape,
|
|
17
|
+
MarkAllRead,
|
|
18
|
+
Next,
|
|
17
19
|
NextUnread,
|
|
18
20
|
OpenArticle,
|
|
21
|
+
OpenOrigin,
|
|
22
|
+
Previous,
|
|
19
23
|
PreviousUnread,
|
|
20
24
|
RefreshFromTheOldReader,
|
|
21
25
|
ToggleShowAll,
|
|
@@ -33,9 +37,13 @@ class MainCommands(CommandsProvider):
|
|
|
33
37
|
The commands for the command palette.
|
|
34
38
|
"""
|
|
35
39
|
yield Escape()
|
|
40
|
+
yield from self.maybe(Next)
|
|
36
41
|
yield from self.maybe(NextUnread)
|
|
42
|
+
yield from self.maybe(Previous)
|
|
37
43
|
yield from self.maybe(PreviousUnread)
|
|
38
44
|
yield from self.maybe(OpenArticle)
|
|
45
|
+
yield from self.maybe(OpenOrigin)
|
|
46
|
+
yield from self.maybe(MarkAllRead)
|
|
39
47
|
yield ToggleShowAll()
|
|
40
48
|
yield RefreshFromTheOldReader()
|
|
41
49
|
yield ChangeTheme()
|
|
@@ -15,6 +15,7 @@ from oldas import (
|
|
|
15
15
|
Folder,
|
|
16
16
|
Folders,
|
|
17
17
|
Session,
|
|
18
|
+
State,
|
|
18
19
|
Subscription,
|
|
19
20
|
Subscriptions,
|
|
20
21
|
)
|
|
@@ -31,6 +32,7 @@ from textual.widgets import Footer, Header
|
|
|
31
32
|
##############################################################################
|
|
32
33
|
# Textual enhanced imports.
|
|
33
34
|
from textual_enhanced.commands import ChangeTheme, Command, Help, Quit
|
|
35
|
+
from textual_enhanced.dialogs import Confirm
|
|
34
36
|
from textual_enhanced.screen import EnhancedScreen
|
|
35
37
|
|
|
36
38
|
##############################################################################
|
|
@@ -38,14 +40,19 @@ from textual_enhanced.screen import EnhancedScreen
|
|
|
38
40
|
from .. import __version__
|
|
39
41
|
from ..commands import (
|
|
40
42
|
Escape,
|
|
43
|
+
MarkAllRead,
|
|
44
|
+
Next,
|
|
41
45
|
NextUnread,
|
|
42
46
|
OpenArticle,
|
|
47
|
+
OpenOrigin,
|
|
48
|
+
Previous,
|
|
43
49
|
PreviousUnread,
|
|
44
50
|
RefreshFromTheOldReader,
|
|
45
51
|
ToggleShowAll,
|
|
46
52
|
)
|
|
47
53
|
from ..data import (
|
|
48
54
|
LocalUnread,
|
|
55
|
+
clean_old_read_articles,
|
|
49
56
|
get_local_articles,
|
|
50
57
|
get_local_folders,
|
|
51
58
|
get_local_subscriptions,
|
|
@@ -134,9 +141,13 @@ class Main(EnhancedScreen[None]):
|
|
|
134
141
|
RefreshFromTheOldReader,
|
|
135
142
|
# Everything else.
|
|
136
143
|
Escape,
|
|
144
|
+
MarkAllRead,
|
|
145
|
+
Next,
|
|
137
146
|
NextUnread,
|
|
147
|
+
Previous,
|
|
138
148
|
PreviousUnread,
|
|
139
149
|
OpenArticle,
|
|
150
|
+
OpenOrigin,
|
|
140
151
|
ChangeTheme,
|
|
141
152
|
]
|
|
142
153
|
|
|
@@ -228,9 +239,21 @@ class Main(EnhancedScreen[None]):
|
|
|
228
239
|
# but okay let's be defensive... (when I can come up with a nice
|
|
229
240
|
# little MRE I'll report it).
|
|
230
241
|
return True
|
|
231
|
-
if action
|
|
242
|
+
if action in (OpenArticle.action_name(), OpenOrigin.action_name()):
|
|
232
243
|
return self.article is not None
|
|
233
|
-
if action in (
|
|
244
|
+
if action in (Next.action_name(), Previous.action_name()):
|
|
245
|
+
return self.articles is not None
|
|
246
|
+
if action in (
|
|
247
|
+
NextUnread.action_name(),
|
|
248
|
+
PreviousUnread.action_name(),
|
|
249
|
+
MarkAllRead.action_name(),
|
|
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.
|
|
234
257
|
return self.articles is not None and any(
|
|
235
258
|
article.is_unread for article in self.articles
|
|
236
259
|
)
|
|
@@ -282,6 +305,10 @@ class Main(EnhancedScreen[None]):
|
|
|
282
305
|
self.post_message(self.NewFolders(folders))
|
|
283
306
|
if subscriptions := get_local_subscriptions():
|
|
284
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}")
|
|
285
312
|
if unread := get_local_unread(folders, subscriptions):
|
|
286
313
|
self.post_message(self.NewUnread(unread))
|
|
287
314
|
# If we've never grabbed data from ToR before, or if it's been long enough...
|
|
@@ -336,12 +363,10 @@ class Main(EnhancedScreen[None]):
|
|
|
336
363
|
self.post_message(self.SubTitle("Comparing against locally-read articles"))
|
|
337
364
|
local_unread_articles = set(get_unread_article_ids())
|
|
338
365
|
if mark_as_read := local_unread_articles - remote_unread_articles:
|
|
339
|
-
self.post_message(
|
|
340
|
-
self.SubTitle(
|
|
341
|
-
f"Articles found read elsewhere on TheOldReader: {len(mark_as_read)}"
|
|
342
|
-
)
|
|
343
|
-
)
|
|
344
366
|
locally_mark_article_ids_read(mark_as_read)
|
|
367
|
+
self.notify(
|
|
368
|
+
f"Articles found read elsewhere on TheOldReader: {len(mark_as_read)}"
|
|
369
|
+
)
|
|
345
370
|
|
|
346
371
|
@on(RefreshFromTheOldReader)
|
|
347
372
|
@work(exclusive=True)
|
|
@@ -459,19 +484,37 @@ class Main(EnhancedScreen[None]):
|
|
|
459
484
|
elif self.focused is self.query_one(Navigation):
|
|
460
485
|
self.app.exit()
|
|
461
486
|
|
|
487
|
+
def action_next_command(self) -> None:
|
|
488
|
+
"""Go to the next article in the currently-viewed category."""
|
|
489
|
+
if self.article is None:
|
|
490
|
+
self.query_one(ArticleList).highlight_next_article()
|
|
491
|
+
else:
|
|
492
|
+
self.query_one(ArticleList).select_next_article()
|
|
493
|
+
|
|
494
|
+
def action_previous_command(self) -> None:
|
|
495
|
+
"""Go to the previous article in the currently-viewed category."""
|
|
496
|
+
if self.article is None:
|
|
497
|
+
self.query_one(ArticleList).highlight_previous_article()
|
|
498
|
+
else:
|
|
499
|
+
self.query_one(ArticleList).select_previous_article()
|
|
500
|
+
|
|
462
501
|
def action_next_unread_command(self) -> None:
|
|
463
502
|
"""Go to the next unread article in the currently-viewed category."""
|
|
464
|
-
if self.
|
|
465
|
-
|
|
503
|
+
if (navigation := self.query_one(Navigation)).has_focus:
|
|
504
|
+
navigation.highlight_next_unread_category()
|
|
505
|
+
elif self.article is None:
|
|
506
|
+
self.query_one(ArticleList).highlight_next_unread_article()
|
|
466
507
|
else:
|
|
467
|
-
self.query_one(ArticleList).
|
|
508
|
+
self.query_one(ArticleList).select_next_unread_article()
|
|
468
509
|
|
|
469
510
|
def action_previous_unread_command(self) -> None:
|
|
470
511
|
"""Go to the previous unread article in the currently-viewed category"""
|
|
471
|
-
if self.
|
|
472
|
-
|
|
512
|
+
if (navigation := self.query_one(Navigation)).has_focus:
|
|
513
|
+
navigation.highlight_previous_unread_category()
|
|
514
|
+
elif self.article is None:
|
|
515
|
+
self.query_one(ArticleList).highlight_previous_unread_article()
|
|
473
516
|
else:
|
|
474
|
-
self.query_one(ArticleList).
|
|
517
|
+
self.query_one(ArticleList).select_previous_unread_article()
|
|
475
518
|
|
|
476
519
|
def action_open_article_command(self) -> None:
|
|
477
520
|
"""Open the current article in a web browser."""
|
|
@@ -485,5 +528,52 @@ class Main(EnhancedScreen[None]):
|
|
|
485
528
|
title="Can't visit",
|
|
486
529
|
)
|
|
487
530
|
|
|
531
|
+
@work
|
|
532
|
+
async def action_mark_all_read_command(self) -> None:
|
|
533
|
+
"""Mark all unread articles in the current category as read."""
|
|
534
|
+
if (current_category := self.query_one(Navigation).current_category) is None:
|
|
535
|
+
return
|
|
536
|
+
if not (
|
|
537
|
+
ids_to_mark_read := [
|
|
538
|
+
article.id for article in self.articles if article.is_unread
|
|
539
|
+
]
|
|
540
|
+
):
|
|
541
|
+
return
|
|
542
|
+
category_description = f"{current_category.__class__.__name__.lower()} '{current_category.name if isinstance(current_category, Folder) else current_category.title}'"
|
|
543
|
+
plural = "s" if len(ids_to_mark_read) > 1 else ""
|
|
544
|
+
if await self.app.push_screen_wait(
|
|
545
|
+
Confirm(
|
|
546
|
+
"Mark all read",
|
|
547
|
+
f"Are you sure you want to mark all unread articles in the {category_description} as read?\n\n"
|
|
548
|
+
f"This will mark {len(ids_to_mark_read)} article{plural} as read.",
|
|
549
|
+
)
|
|
550
|
+
):
|
|
551
|
+
if await self._session.add_tag(ids_to_mark_read, State.READ):
|
|
552
|
+
locally_mark_article_ids_read(ids_to_mark_read)
|
|
553
|
+
self.post_message(
|
|
554
|
+
self.NewUnread(get_local_unread(self.folders, self.subscriptions))
|
|
555
|
+
)
|
|
556
|
+
self._refresh_article_list()
|
|
557
|
+
self.notify(
|
|
558
|
+
f"{len(ids_to_mark_read)} article{plural} marked read for {category_description}"
|
|
559
|
+
)
|
|
560
|
+
else:
|
|
561
|
+
self.notify(
|
|
562
|
+
"Failed to mark as read on TheOldReader",
|
|
563
|
+
severity="error",
|
|
564
|
+
)
|
|
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
|
+
|
|
488
578
|
|
|
489
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,27 +152,53 @@ 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
|
|
185
167
|
self.notify("No more unread articles")
|
|
186
168
|
return False
|
|
187
169
|
|
|
188
|
-
def
|
|
170
|
+
def highlight_next_article(self) -> None:
|
|
171
|
+
"""Highlight the next article in the list."""
|
|
172
|
+
self.call_later(self.run_action, "cursor_down")
|
|
173
|
+
|
|
174
|
+
def highlight_previous_article(self) -> None:
|
|
175
|
+
"""Highlight the previous article in the list."""
|
|
176
|
+
self.call_later(self.run_action, "cursor_up")
|
|
177
|
+
|
|
178
|
+
def highlight_next_unread_article(self) -> None:
|
|
189
179
|
"""Highlight the next unread article in the list."""
|
|
190
180
|
self._highlight_unread("next")
|
|
191
181
|
|
|
192
|
-
def
|
|
182
|
+
def highlight_previous_unread_article(self) -> None:
|
|
193
183
|
"""Highlight the previous unread article in the list."""
|
|
194
184
|
self._highlight_unread("previous")
|
|
195
185
|
|
|
196
|
-
def
|
|
186
|
+
def select_next_article(self) -> None:
|
|
187
|
+
"""Select the next article in the list."""
|
|
188
|
+
self.call_later(self.run_action, "cursor_down")
|
|
189
|
+
self.call_later(self.run_action, "select")
|
|
190
|
+
|
|
191
|
+
def select_previous_article(self) -> None:
|
|
192
|
+
"""Select the previous article in the list."""
|
|
193
|
+
self.call_later(self.run_action, "cursor_up")
|
|
194
|
+
self.call_later(self.run_action, "select")
|
|
195
|
+
|
|
196
|
+
def select_next_unread_article(self) -> None:
|
|
197
197
|
"""Select the next unread article in the list."""
|
|
198
198
|
if self._highlight_unread("next"):
|
|
199
199
|
self.call_later(self.run_action, "select")
|
|
200
200
|
|
|
201
|
-
def
|
|
201
|
+
def select_previous_unread_article(self) -> None:
|
|
202
202
|
"""Select the next unread article in the list."""
|
|
203
203
|
if self._highlight_unread("previous"):
|
|
204
204
|
self.call_later(self.run_action, "select")
|
|
@@ -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
|