oldnews 0.1.0__tar.gz → 0.3.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.3.0}/PKG-INFO +3 -4
- {oldnews-0.1.0 → oldnews-0.3.0}/pyproject.toml +3 -4
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/__main__.py +67 -24
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/commands/__init__.py +2 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/commands/main.py +7 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/__init__.py +4 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/db.py +15 -9
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/local_articles.py +18 -3
- oldnews-0.3.0/src/oldnews/data/reset.py +26 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/providers/main.py +2 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/screens/main.py +60 -100
- oldnews-0.3.0/src/oldnews/sync/__init__.py +11 -0
- oldnews-0.3.0/src/oldnews/sync/sync.py +158 -0
- oldnews-0.3.0/src/oldnews/widgets/_after_highlight.py +53 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/widgets/article_content.py +29 -7
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/widgets/article_list.py +19 -37
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/widgets/navigation.py +36 -1
- {oldnews-0.1.0 → oldnews-0.3.0}/README.md +0 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/__init__.py +0 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/auth.py +0 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/config.py +0 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/last_grab.py +0 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/local_folders.py +0 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/local_subscriptions.py +0 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/local_unread.py +0 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/locations.py +0 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/data/navigation_state.py +0 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/oldnews.py +0 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/providers/__init__.py +0 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/py.typed +0 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/screens/__init__.py +0 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/screens/login.py +0 -0
- {oldnews-0.1.0 → oldnews-0.3.0}/src/oldnews/widgets/__init__.py +0 -0
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oldnews
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
7
7
|
Author-email: Dave Pearson <davep@davep.org>
|
|
8
8
|
License-Expression: GPL-3.0-or-later
|
|
9
|
-
Classifier: Development Status ::
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
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.3.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",
|
|
@@ -27,11 +27,10 @@ dependencies = [
|
|
|
27
27
|
"xdg-base-dirs>=6.0.2",
|
|
28
28
|
]
|
|
29
29
|
classifiers = [
|
|
30
|
-
"Development Status ::
|
|
30
|
+
"Development Status :: 4 - Beta",
|
|
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",
|
|
@@ -9,7 +9,7 @@ from operator import attrgetter
|
|
|
9
9
|
##############################################################################
|
|
10
10
|
# Local imports.
|
|
11
11
|
from . import __doc__, __version__
|
|
12
|
-
from .data import initialise_database
|
|
12
|
+
from .data import initialise_database, reset_data
|
|
13
13
|
from .oldnews import OldNews
|
|
14
14
|
|
|
15
15
|
|
|
@@ -37,27 +37,48 @@ def get_args() -> Namespace:
|
|
|
37
37
|
version=f"%(prog)s v{__version__}",
|
|
38
38
|
)
|
|
39
39
|
|
|
40
|
-
# Add --
|
|
40
|
+
# Add --theme
|
|
41
41
|
parser.add_argument(
|
|
42
|
-
"
|
|
43
|
-
"--
|
|
42
|
+
"-t",
|
|
43
|
+
"--theme",
|
|
44
|
+
help="Set the theme for the application (see `themes` command for available themes)",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Allow for commands on the command line.
|
|
48
|
+
sub_parser = parser.add_subparsers(
|
|
49
|
+
dest="command", help="Available commands", required=False
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Add the 'license' command.
|
|
53
|
+
sub_parser.add_parser(
|
|
54
|
+
"license",
|
|
55
|
+
aliases=["licence"],
|
|
44
56
|
help="Show license information",
|
|
45
|
-
action="store_true",
|
|
46
57
|
)
|
|
47
58
|
|
|
48
|
-
# Add
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
"--bindings",
|
|
59
|
+
# Add the 'bindings' command.
|
|
60
|
+
sub_parser.add_parser(
|
|
61
|
+
"bindings",
|
|
52
62
|
help="List commands that can have their bindings changed",
|
|
53
|
-
action="store_true",
|
|
54
63
|
)
|
|
55
64
|
|
|
56
|
-
# Add
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
# Add the 'themes' command.
|
|
66
|
+
sub_parser.add_parser(
|
|
67
|
+
"themes", help="List the available themes that can be used with --theme"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Add the 'reset' command.
|
|
71
|
+
reset = sub_parser.add_parser(
|
|
72
|
+
"reset", help="Remove all data downloaded from TheOldReader"
|
|
73
|
+
)
|
|
74
|
+
reset.add_argument(
|
|
75
|
+
"-l", "--logout", help="Force a logout from TheOldReader", action="store_true"
|
|
76
|
+
)
|
|
77
|
+
reset.add_argument(
|
|
78
|
+
"-y",
|
|
79
|
+
"--yes",
|
|
80
|
+
help="Perform the reset without confirming first",
|
|
81
|
+
action="store_true",
|
|
61
82
|
)
|
|
62
83
|
|
|
63
84
|
# Finally, parse the command line.
|
|
@@ -91,18 +112,40 @@ def show_themes() -> None:
|
|
|
91
112
|
print(theme)
|
|
92
113
|
|
|
93
114
|
|
|
115
|
+
##############################################################################
|
|
116
|
+
def reset_news(args: Namespace) -> None:
|
|
117
|
+
"""Perform a reset on the news data.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
args: The command line arguments.
|
|
121
|
+
"""
|
|
122
|
+
from rich.prompt import Confirm
|
|
123
|
+
|
|
124
|
+
logout = " and log you out" if args.logout else ""
|
|
125
|
+
if args.yes or Confirm().ask(
|
|
126
|
+
f"This will erase all the local news data{logout}; are you sure?", default=False
|
|
127
|
+
):
|
|
128
|
+
reset_data(args.logout)
|
|
129
|
+
print("Local data erased")
|
|
130
|
+
if args.logout:
|
|
131
|
+
print("Login token removed")
|
|
132
|
+
|
|
133
|
+
|
|
94
134
|
##############################################################################
|
|
95
135
|
def main() -> None:
|
|
96
136
|
"""Main entry function."""
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
137
|
+
match (args := get_args()).command:
|
|
138
|
+
case "reset":
|
|
139
|
+
reset_news(args)
|
|
140
|
+
case "license" | "licence":
|
|
141
|
+
print(cleandoc(OldNews.HELP_LICENSE))
|
|
142
|
+
case "bindings":
|
|
143
|
+
show_bindable_commands()
|
|
144
|
+
case "themes":
|
|
145
|
+
show_themes()
|
|
146
|
+
case _:
|
|
147
|
+
initialise_database()
|
|
148
|
+
OldNews(args).run()
|
|
106
149
|
|
|
107
150
|
|
|
108
151
|
### __main__.py ends here
|
|
@@ -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,
|
|
@@ -22,11 +23,13 @@ from .local_folders import get_local_folders, save_local_folders
|
|
|
22
23
|
from .local_subscriptions import get_local_subscriptions, save_local_subscriptions
|
|
23
24
|
from .local_unread import LocalUnread, get_local_unread, total_unread
|
|
24
25
|
from .navigation_state import get_navigation_state, save_navigation_state
|
|
26
|
+
from .reset import reset_data
|
|
25
27
|
|
|
26
28
|
##############################################################################
|
|
27
29
|
# Exports.
|
|
28
30
|
__all__ = [
|
|
29
31
|
"Configuration",
|
|
32
|
+
"clean_old_read_articles",
|
|
30
33
|
"get_auth_token",
|
|
31
34
|
"get_local_articles",
|
|
32
35
|
"get_local_folders",
|
|
@@ -41,6 +44,7 @@ __all__ = [
|
|
|
41
44
|
"locally_mark_article_ids_read",
|
|
42
45
|
"LocalUnread",
|
|
43
46
|
"remember_we_last_grabbed_at",
|
|
47
|
+
"reset_data",
|
|
44
48
|
"save_configuration",
|
|
45
49
|
"save_local_articles",
|
|
46
50
|
"save_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
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Provides a tool to reset all the data."""
|
|
2
|
+
|
|
3
|
+
##############################################################################
|
|
4
|
+
# Python imports.
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
##############################################################################
|
|
8
|
+
# Local imports.
|
|
9
|
+
from .locations import data_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
##############################################################################
|
|
13
|
+
def reset_data(logout: bool) -> None:
|
|
14
|
+
"""Erase all the data.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
logout: Should any token file be removed too?
|
|
18
|
+
"""
|
|
19
|
+
to_remove: list[Path] = []
|
|
20
|
+
for pattern in ("*.table", "*.db", "*.log", *((".token",) if logout else ())):
|
|
21
|
+
to_remove.extend(data_dir().glob(pattern))
|
|
22
|
+
for data_file in to_remove:
|
|
23
|
+
data_file.unlink()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### reset.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()
|
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
##############################################################################
|
|
4
4
|
# Python imports.
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
-
from datetime import datetime, timedelta
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
7
|
from webbrowser import open as open_url
|
|
8
8
|
|
|
9
9
|
##############################################################################
|
|
10
10
|
# OldAs imports.
|
|
11
11
|
from oldas import (
|
|
12
12
|
Article,
|
|
13
|
-
ArticleIDs,
|
|
14
13
|
Articles,
|
|
15
14
|
Folder,
|
|
16
15
|
Folders,
|
|
@@ -44,6 +43,7 @@ from ..commands import (
|
|
|
44
43
|
Next,
|
|
45
44
|
NextUnread,
|
|
46
45
|
OpenArticle,
|
|
46
|
+
OpenOrigin,
|
|
47
47
|
Previous,
|
|
48
48
|
PreviousUnread,
|
|
49
49
|
RefreshFromTheOldReader,
|
|
@@ -51,23 +51,20 @@ from ..commands import (
|
|
|
51
51
|
)
|
|
52
52
|
from ..data import (
|
|
53
53
|
LocalUnread,
|
|
54
|
+
clean_old_read_articles,
|
|
54
55
|
get_local_articles,
|
|
55
56
|
get_local_folders,
|
|
56
57
|
get_local_subscriptions,
|
|
57
58
|
get_local_unread,
|
|
58
|
-
get_unread_article_ids,
|
|
59
59
|
last_grabbed_data_at,
|
|
60
60
|
load_configuration,
|
|
61
61
|
locally_mark_article_ids_read,
|
|
62
62
|
locally_mark_read,
|
|
63
|
-
remember_we_last_grabbed_at,
|
|
64
|
-
save_local_articles,
|
|
65
|
-
save_local_folders,
|
|
66
|
-
save_local_subscriptions,
|
|
67
63
|
total_unread,
|
|
68
64
|
update_configuration,
|
|
69
65
|
)
|
|
70
66
|
from ..providers import MainCommands
|
|
67
|
+
from ..sync import ToRSync
|
|
71
68
|
from ..widgets import ArticleContent, ArticleList, Navigation
|
|
72
69
|
|
|
73
70
|
|
|
@@ -145,6 +142,7 @@ class Main(EnhancedScreen[None]):
|
|
|
145
142
|
Previous,
|
|
146
143
|
PreviousUnread,
|
|
147
144
|
OpenArticle,
|
|
145
|
+
OpenOrigin,
|
|
148
146
|
ChangeTheme,
|
|
149
147
|
]
|
|
150
148
|
|
|
@@ -195,6 +193,9 @@ class Main(EnhancedScreen[None]):
|
|
|
195
193
|
counts: LocalUnread
|
|
196
194
|
"""The new unread counts."""
|
|
197
195
|
|
|
196
|
+
class SyncFinished(Message):
|
|
197
|
+
"""Message sent when a sync from TheOldReader is finished."""
|
|
198
|
+
|
|
198
199
|
def __init__(self, session: Session) -> None:
|
|
199
200
|
"""Initialise the main screen."""
|
|
200
201
|
super().__init__()
|
|
@@ -236,7 +237,7 @@ class Main(EnhancedScreen[None]):
|
|
|
236
237
|
# but okay let's be defensive... (when I can come up with a nice
|
|
237
238
|
# little MRE I'll report it).
|
|
238
239
|
return True
|
|
239
|
-
if action
|
|
240
|
+
if action in (OpenArticle.action_name(), OpenOrigin.action_name()):
|
|
240
241
|
return self.article is not None
|
|
241
242
|
if action in (Next.action_name(), Previous.action_name()):
|
|
242
243
|
return self.articles is not None
|
|
@@ -245,6 +246,12 @@ class Main(EnhancedScreen[None]):
|
|
|
245
246
|
PreviousUnread.action_name(),
|
|
246
247
|
MarkAllRead.action_name(),
|
|
247
248
|
):
|
|
249
|
+
# If we're inside the navigation panel...
|
|
250
|
+
if self.query_one(Navigation).has_focus:
|
|
251
|
+
# ...we just care if there's anything unread somewhere.
|
|
252
|
+
return any(total for total in self.unread.values())
|
|
253
|
+
# Otherwise we care if we can see a current list of articles and
|
|
254
|
+
# if there's something unread amongst them.
|
|
248
255
|
return self.articles is not None and any(
|
|
249
256
|
article.is_unread for article in self.articles
|
|
250
257
|
)
|
|
@@ -289,6 +296,11 @@ class Main(EnhancedScreen[None]):
|
|
|
289
296
|
self.unread = message.counts
|
|
290
297
|
self.post_message(self.SubTitle(""))
|
|
291
298
|
|
|
299
|
+
def _refresh_article_list(self) -> None:
|
|
300
|
+
"""Refresh the content of the article list."""
|
|
301
|
+
if self.current_category:
|
|
302
|
+
self.articles = get_local_articles(self.current_category, not self.show_all)
|
|
303
|
+
|
|
292
304
|
@work(thread=True, exclusive=True)
|
|
293
305
|
def _load_locally(self) -> None:
|
|
294
306
|
"""Load up any locally-held data."""
|
|
@@ -296,8 +308,13 @@ class Main(EnhancedScreen[None]):
|
|
|
296
308
|
self.post_message(self.NewFolders(folders))
|
|
297
309
|
if subscriptions := get_local_subscriptions():
|
|
298
310
|
self.post_message(self.NewSubscriptions(subscriptions))
|
|
311
|
+
if cleaned := clean_old_read_articles(
|
|
312
|
+
timedelta(days=load_configuration().local_history)
|
|
313
|
+
):
|
|
314
|
+
self.notify(f"Old read articles cleaned from local storage: {cleaned}")
|
|
299
315
|
if unread := get_local_unread(folders, subscriptions):
|
|
300
316
|
self.post_message(self.NewUnread(unread))
|
|
317
|
+
self._refresh_article_list()
|
|
301
318
|
# If we've never grabbed data from ToR before, or if it's been long enough...
|
|
302
319
|
if (last_grabbed := last_grabbed_data_at()) is None or (
|
|
303
320
|
(datetime.now() - last_grabbed).seconds
|
|
@@ -306,95 +323,27 @@ class Main(EnhancedScreen[None]):
|
|
|
306
323
|
# ...kick off a refresh from TheOldReader.
|
|
307
324
|
self.post_message(RefreshFromTheOldReader())
|
|
308
325
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
)
|
|
314
|
-
new_grab = datetime.now(timezone.utc)
|
|
315
|
-
loaded = 0
|
|
316
|
-
async for article in Articles.stream_new_since(
|
|
317
|
-
self._session, last_grabbed, n=10
|
|
318
|
-
):
|
|
319
|
-
# I've encountered articles that don't have an origin stream ID,
|
|
320
|
-
# which means that I can't relate them back to a stream, which
|
|
321
|
-
# means I'll never see them anyway...
|
|
322
|
-
if not article.origin.stream_id:
|
|
323
|
-
continue
|
|
324
|
-
# TODO: Right now I'm saving articles one at a time; perhaps I
|
|
325
|
-
# should save them in small batches? This would be simple enough
|
|
326
|
-
# -- perhaps same them in batches the same size as the buffer
|
|
327
|
-
# window I'm using right now (currently 10 articles per trip to
|
|
328
|
-
# ToR).
|
|
329
|
-
save_local_articles(Articles([article]))
|
|
330
|
-
loaded += 1
|
|
331
|
-
if (loaded % 10) == 0:
|
|
332
|
-
self.post_message(
|
|
333
|
-
self.SubTitle(f"Downloading articles from TheOldReader: {loaded}")
|
|
334
|
-
)
|
|
335
|
-
if loaded:
|
|
336
|
-
self.notify(f"Articles downloaded: {loaded}")
|
|
337
|
-
else:
|
|
338
|
-
self.notify("No new articles found on TheOldReader")
|
|
339
|
-
remember_we_last_grabbed_at(new_grab)
|
|
340
|
-
|
|
341
|
-
async def _refresh_read_status(self) -> None:
|
|
342
|
-
"""Refresh the read status from the server."""
|
|
343
|
-
self.post_message(
|
|
344
|
-
self.SubTitle("Getting list of unread articles from TheOldReader")
|
|
345
|
-
)
|
|
346
|
-
remote_unread_articles = set(
|
|
347
|
-
article_id.full_id
|
|
348
|
-
for article_id in await ArticleIDs.load_unread(self._session)
|
|
349
|
-
)
|
|
350
|
-
self.post_message(self.SubTitle("Comparing against locally-read articles"))
|
|
351
|
-
local_unread_articles = set(get_unread_article_ids())
|
|
352
|
-
if mark_as_read := local_unread_articles - remote_unread_articles:
|
|
353
|
-
locally_mark_article_ids_read(mark_as_read)
|
|
354
|
-
self.notify(
|
|
355
|
-
f"Articles found read elsewhere on TheOldReader: {len(mark_as_read)}"
|
|
356
|
-
)
|
|
326
|
+
@on(SyncFinished)
|
|
327
|
+
def _sync_finished(self) -> None:
|
|
328
|
+
"""Clean up after a sync from TheOldReader has finished."""
|
|
329
|
+
self._refresh_article_list()
|
|
330
|
+
self.post_message(self.SubTitle(""))
|
|
357
331
|
|
|
358
332
|
@on(RefreshFromTheOldReader)
|
|
359
333
|
@work(exclusive=True)
|
|
360
334
|
async def action_refresh_from_the_old_reader_command(self) -> None:
|
|
361
335
|
"""Load the main data from TheOldReader."""
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
self.
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
save_local_subscriptions(await Subscriptions.load(self._session))
|
|
374
|
-
)
|
|
375
|
-
)
|
|
376
|
-
|
|
377
|
-
# Download the latest articles we don't know about.
|
|
378
|
-
if never_grabbed_before := last_grabbed_data_at() is None:
|
|
379
|
-
self.post_message(self.SubTitle("Getting available articles"))
|
|
380
|
-
else:
|
|
381
|
-
self.post_message(
|
|
382
|
-
self.SubTitle(f"Getting articles new since {last_grabbed_data_at()}")
|
|
383
|
-
)
|
|
384
|
-
await self._download_newest_articles()
|
|
385
|
-
|
|
386
|
-
# If we have grabbed data before, let's try and sync up what's been read.
|
|
387
|
-
if not never_grabbed_before:
|
|
388
|
-
await self._refresh_read_status()
|
|
389
|
-
|
|
390
|
-
# Recalculate the unread counts.
|
|
391
|
-
self.post_message(self.SubTitle("Calculating unread counts"))
|
|
392
|
-
self.post_message(
|
|
393
|
-
self.NewUnread(get_local_unread(self.folders, self.subscriptions))
|
|
394
|
-
)
|
|
395
|
-
|
|
396
|
-
# Finally we're all done.
|
|
397
|
-
self.post_message(self.SubTitle(""))
|
|
336
|
+
await ToRSync(
|
|
337
|
+
self._session,
|
|
338
|
+
on_new_step=lambda step: self.post_message(self.SubTitle(step)),
|
|
339
|
+
on_new_result=lambda result: self.notify(result),
|
|
340
|
+
on_new_folders=lambda folders: self.post_message(self.NewFolders(folders)),
|
|
341
|
+
on_new_subscriptions=lambda subscriptions: self.post_message(
|
|
342
|
+
self.NewSubscriptions(subscriptions)
|
|
343
|
+
),
|
|
344
|
+
on_new_unread=lambda unread: self.post_message(self.NewUnread(unread)),
|
|
345
|
+
on_sync_finished=lambda: self.post_message(self.SyncFinished()),
|
|
346
|
+
).refresh()
|
|
398
347
|
|
|
399
348
|
@on(Navigation.CategorySelected)
|
|
400
349
|
def _handle_navigaion_selection(self, message: Navigation.CategorySelected) -> None:
|
|
@@ -405,14 +354,9 @@ class Main(EnhancedScreen[None]):
|
|
|
405
354
|
"""
|
|
406
355
|
self.current_category = message.category
|
|
407
356
|
self.article = None
|
|
408
|
-
self.
|
|
357
|
+
self._refresh_article_list()
|
|
409
358
|
self.query_one(ArticleList).focus()
|
|
410
359
|
|
|
411
|
-
def _refresh_article_list(self) -> None:
|
|
412
|
-
"""Refresh the content of the article list."""
|
|
413
|
-
if category := self.query_one(Navigation).current_category:
|
|
414
|
-
self.articles = get_local_articles(category, not self.show_all)
|
|
415
|
-
|
|
416
360
|
def _watch_show_all(self) -> None:
|
|
417
361
|
"""Handle changes to the show all flag."""
|
|
418
362
|
self._refresh_article_list()
|
|
@@ -487,14 +431,18 @@ class Main(EnhancedScreen[None]):
|
|
|
487
431
|
|
|
488
432
|
def action_next_unread_command(self) -> None:
|
|
489
433
|
"""Go to the next unread article in the currently-viewed category."""
|
|
490
|
-
if self.
|
|
434
|
+
if (navigation := self.query_one(Navigation)).has_focus:
|
|
435
|
+
navigation.highlight_next_unread_category()
|
|
436
|
+
elif self.article is None:
|
|
491
437
|
self.query_one(ArticleList).highlight_next_unread_article()
|
|
492
438
|
else:
|
|
493
439
|
self.query_one(ArticleList).select_next_unread_article()
|
|
494
440
|
|
|
495
441
|
def action_previous_unread_command(self) -> None:
|
|
496
442
|
"""Go to the previous unread article in the currently-viewed category"""
|
|
497
|
-
if self.
|
|
443
|
+
if (navigation := self.query_one(Navigation)).has_focus:
|
|
444
|
+
navigation.highlight_previous_unread_category()
|
|
445
|
+
elif self.article is None:
|
|
498
446
|
self.query_one(ArticleList).highlight_previous_unread_article()
|
|
499
447
|
else:
|
|
500
448
|
self.query_one(ArticleList).select_previous_unread_article()
|
|
@@ -546,5 +494,17 @@ class Main(EnhancedScreen[None]):
|
|
|
546
494
|
severity="error",
|
|
547
495
|
)
|
|
548
496
|
|
|
497
|
+
def action_open_origin_command(self) -> None:
|
|
498
|
+
"""Open the origin of the current article in the web browser."""
|
|
499
|
+
if self.article is not None:
|
|
500
|
+
if self.article.origin.html_url:
|
|
501
|
+
open_url(self.article.origin.html_url)
|
|
502
|
+
else:
|
|
503
|
+
self.notify(
|
|
504
|
+
"No URL available for the article's origin",
|
|
505
|
+
severity="error",
|
|
506
|
+
title="Can't visit",
|
|
507
|
+
)
|
|
508
|
+
|
|
549
509
|
|
|
550
510
|
### main.py ends here
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Code to sync the local data from TheOldReader."""
|
|
2
|
+
|
|
3
|
+
##############################################################################
|
|
4
|
+
# Local imports.
|
|
5
|
+
from .sync import ToRSync
|
|
6
|
+
|
|
7
|
+
##############################################################################
|
|
8
|
+
# Exports.
|
|
9
|
+
__all__ = ["ToRSync"]
|
|
10
|
+
|
|
11
|
+
### __init__.py ends here
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Provides a class to sync data from TheOldReader."""
|
|
2
|
+
|
|
3
|
+
##############################################################################
|
|
4
|
+
# Python imports.
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
##############################################################################
|
|
10
|
+
# OldAS imports.
|
|
11
|
+
from oldas import ArticleIDs, Articles, Folders, Session, Subscriptions
|
|
12
|
+
|
|
13
|
+
##############################################################################
|
|
14
|
+
from ..data import (
|
|
15
|
+
LocalUnread,
|
|
16
|
+
get_local_unread,
|
|
17
|
+
get_unread_article_ids,
|
|
18
|
+
last_grabbed_data_at,
|
|
19
|
+
load_configuration,
|
|
20
|
+
locally_mark_article_ids_read,
|
|
21
|
+
remember_we_last_grabbed_at,
|
|
22
|
+
save_local_articles,
|
|
23
|
+
save_local_folders,
|
|
24
|
+
save_local_subscriptions,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
##############################################################################
|
|
28
|
+
type Callback = Callable[[], Any] | None
|
|
29
|
+
"""Type of a callback with no arguments."""
|
|
30
|
+
type CallbackWith[T] = Callable[[T], Any] | None
|
|
31
|
+
"""Type of callback with a single argument."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
##############################################################################
|
|
35
|
+
@dataclass
|
|
36
|
+
class ToRSync:
|
|
37
|
+
"""Class that handles syncing data from TheOldReader."""
|
|
38
|
+
|
|
39
|
+
session: Session
|
|
40
|
+
"""The TheOldReader API session object."""
|
|
41
|
+
on_new_step: CallbackWith[str] = None
|
|
42
|
+
"""Function to call when a new step starts."""
|
|
43
|
+
on_new_result: CallbackWith[str] = None
|
|
44
|
+
"""Function to call when a result should be communicated."""
|
|
45
|
+
on_new_folders: CallbackWith[Folders] = None
|
|
46
|
+
"""Function to call when new folders are acquired."""
|
|
47
|
+
on_new_subscriptions: CallbackWith[Subscriptions] = None
|
|
48
|
+
"""Function to call when new subscriptions are acquired."""
|
|
49
|
+
on_new_unread: CallbackWith[LocalUnread] = None
|
|
50
|
+
"""Function to call when new unread counts are calculated."""
|
|
51
|
+
on_sync_finished: Callback = None
|
|
52
|
+
"""Function to call when the sync has finished."""
|
|
53
|
+
|
|
54
|
+
def _step(self, step: str) -> None:
|
|
55
|
+
"""Mark a new step.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
step: The step that is happening.
|
|
59
|
+
"""
|
|
60
|
+
if self.on_new_step:
|
|
61
|
+
self.on_new_step(step)
|
|
62
|
+
|
|
63
|
+
def _result(self, result: str) -> None:
|
|
64
|
+
"""Show a new result.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
result: The result that should be shown.
|
|
68
|
+
"""
|
|
69
|
+
if self.on_new_result:
|
|
70
|
+
self.on_new_result(result)
|
|
71
|
+
|
|
72
|
+
async def _download_newest_articles(self) -> None:
|
|
73
|
+
"""Download the latest articles available."""
|
|
74
|
+
last_grabbed = last_grabbed_data_at() or (
|
|
75
|
+
datetime.now() - timedelta(days=load_configuration().local_history)
|
|
76
|
+
)
|
|
77
|
+
new_grab = datetime.now(timezone.utc)
|
|
78
|
+
loaded = 0
|
|
79
|
+
async for article in Articles.stream_new_since(
|
|
80
|
+
self.session, last_grabbed, n=10
|
|
81
|
+
):
|
|
82
|
+
# I've encountered articles that don't have an origin stream ID,
|
|
83
|
+
# which means that I can't relate them back to a stream, which
|
|
84
|
+
# means I'll never see them anyway...
|
|
85
|
+
if not article.origin.stream_id:
|
|
86
|
+
continue
|
|
87
|
+
# TODO: Right now I'm saving articles one at a time; perhaps I
|
|
88
|
+
# should save them in small batches? This would be simple enough
|
|
89
|
+
# -- perhaps same them in batches the same size as the buffer
|
|
90
|
+
# window I'm using right now (currently 10 articles per trip to
|
|
91
|
+
# ToR).
|
|
92
|
+
save_local_articles(Articles([article]))
|
|
93
|
+
loaded += 1
|
|
94
|
+
if (loaded % 10) == 0:
|
|
95
|
+
self._step(f"Downloading articles from TheOldReader: {loaded}")
|
|
96
|
+
if loaded:
|
|
97
|
+
self._result(f"Articles downloaded: {loaded}")
|
|
98
|
+
else:
|
|
99
|
+
self._result("No new articles found on TheOldReader")
|
|
100
|
+
remember_we_last_grabbed_at(new_grab)
|
|
101
|
+
|
|
102
|
+
async def _refresh_read_status(self) -> None:
|
|
103
|
+
"""Refresh the read status from the server."""
|
|
104
|
+
self._step("Getting list of unread articles from TheOldReader")
|
|
105
|
+
remote_unread_articles = set(
|
|
106
|
+
article_id.full_id
|
|
107
|
+
for article_id in await ArticleIDs.load_unread(self.session)
|
|
108
|
+
)
|
|
109
|
+
self._step("Comparing against locally-read articles")
|
|
110
|
+
local_unread_articles = set(get_unread_article_ids())
|
|
111
|
+
if mark_as_read := local_unread_articles - remote_unread_articles:
|
|
112
|
+
locally_mark_article_ids_read(mark_as_read)
|
|
113
|
+
self._result(
|
|
114
|
+
f"Articles found read elsewhere on TheOldReader: {len(mark_as_read)}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
async def refresh(self) -> None:
|
|
118
|
+
"""Refresh the data from TheOldReader.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
session: The TheOldReader API session object.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
# Get the folder list.
|
|
125
|
+
self._step("Getting folder list")
|
|
126
|
+
folders = save_local_folders(await Folders.load(self.session))
|
|
127
|
+
if self.on_new_folders:
|
|
128
|
+
self.on_new_folders(folders)
|
|
129
|
+
|
|
130
|
+
# Get the subscriptions list.
|
|
131
|
+
self._step("Getting subscriptions list")
|
|
132
|
+
subscriptions = save_local_subscriptions(await Subscriptions.load(self.session))
|
|
133
|
+
if self.on_new_subscriptions:
|
|
134
|
+
self.on_new_subscriptions(subscriptions)
|
|
135
|
+
|
|
136
|
+
# Download the latest articles we don't know about.
|
|
137
|
+
if never_grabbed_before := ((last_grab := last_grabbed_data_at()) is None):
|
|
138
|
+
self._step("Getting available articles")
|
|
139
|
+
else:
|
|
140
|
+
self._step(f"Getting new articles since {last_grab}")
|
|
141
|
+
await self._download_newest_articles()
|
|
142
|
+
|
|
143
|
+
# If we have grabbed data before, let's try and sync up what's been read.
|
|
144
|
+
if not never_grabbed_before:
|
|
145
|
+
await self._refresh_read_status()
|
|
146
|
+
|
|
147
|
+
# Recalculate the unread counts.
|
|
148
|
+
self._step("Calculating unread counts")
|
|
149
|
+
unread = get_local_unread(folders, subscriptions)
|
|
150
|
+
if self.on_new_unread:
|
|
151
|
+
self.on_new_unread(unread)
|
|
152
|
+
|
|
153
|
+
# Finally we're all done.
|
|
154
|
+
if self.on_sync_finished:
|
|
155
|
+
self.on_sync_finished()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
### sync.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,9 +15,9 @@ 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
|
-
from textual.widgets import Label, Markdown
|
|
20
|
+
from textual.widgets import Label, Markdown, Rule
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
##############################################################################
|
|
@@ -32,18 +32,36 @@ class ArticleContent(Vertical):
|
|
|
32
32
|
display: block;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
Rule.-horizontal {
|
|
36
|
+
margin: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
35
39
|
#header {
|
|
36
40
|
height: auto;
|
|
37
41
|
padding: 0 1 0 1;
|
|
38
42
|
|
|
43
|
+
Horizontal {
|
|
44
|
+
height: auto;
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
#title {
|
|
40
48
|
color: $text-accent;
|
|
49
|
+
width: 1fr;
|
|
41
50
|
}
|
|
42
51
|
#published, #link {
|
|
43
52
|
color: $text-muted;
|
|
44
53
|
}
|
|
45
54
|
}
|
|
46
55
|
|
|
56
|
+
&:focus-within {
|
|
57
|
+
#header, Rule {
|
|
58
|
+
background: $boost;
|
|
59
|
+
}
|
|
60
|
+
Rule {
|
|
61
|
+
color: $border;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
Markdown {
|
|
48
66
|
padding: 0 1 0 1;
|
|
49
67
|
}
|
|
@@ -62,16 +80,18 @@ class ArticleContent(Vertical):
|
|
|
62
80
|
|
|
63
81
|
def compose(self) -> ComposeResult:
|
|
64
82
|
"""Compose the content of the widget."""
|
|
83
|
+
yield Rule()
|
|
65
84
|
with Vertical(id="header"):
|
|
66
|
-
|
|
67
|
-
|
|
85
|
+
with Horizontal():
|
|
86
|
+
yield Label(id="title", markup=False)
|
|
87
|
+
yield Label(id="published")
|
|
68
88
|
yield Label(id="link", markup=False)
|
|
89
|
+
yield Rule()
|
|
69
90
|
with VerticalScroll():
|
|
70
91
|
yield Markdown()
|
|
71
92
|
|
|
72
|
-
def _watch_article(self) -> None:
|
|
93
|
+
async def _watch_article(self) -> None:
|
|
73
94
|
"""React to the article being updated."""
|
|
74
|
-
self.set_class(self.article is not None, "--has-article")
|
|
75
95
|
if self.article is not None:
|
|
76
96
|
self.query_one("#title", Label).update(self.article.title)
|
|
77
97
|
self.query_one("#published", Label).update(str(self.article.published))
|
|
@@ -81,7 +101,9 @@ class ArticleContent(Vertical):
|
|
|
81
101
|
else:
|
|
82
102
|
link.visible = True
|
|
83
103
|
link.update(self.article.html_url)
|
|
84
|
-
self.query_one(Markdown).update(convert(self.article.summary.content))
|
|
104
|
+
await self.query_one(Markdown).update(convert(self.article.summary.content))
|
|
105
|
+
self.query_one(VerticalScroll).scroll_home(animate=False)
|
|
106
|
+
self.set_class(self.article is not None, "--has-article")
|
|
85
107
|
|
|
86
108
|
def focus(self, scroll_visible: bool = True) -> Self:
|
|
87
109
|
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
|