oldnews 0.2.0__tar.gz → 0.4.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.2.0 → oldnews-0.4.0}/PKG-INFO +2 -2
- {oldnews-0.2.0 → oldnews-0.4.0}/pyproject.toml +2 -2
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/__main__.py +67 -24
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/commands/__init__.py +10 -2
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/commands/main.py +30 -2
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/__init__.py +2 -0
- oldnews-0.4.0/src/oldnews/data/reset.py +26 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/providers/main.py +10 -2
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/screens/main.py +116 -107
- oldnews-0.4.0/src/oldnews/sync.py +158 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/widgets/article_content.py +17 -2
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/widgets/navigation.py +14 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/README.md +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/__init__.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/auth.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/config.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/db.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/last_grab.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/local_articles.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/local_folders.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/local_subscriptions.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/local_unread.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/locations.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/data/navigation_state.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/oldnews.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/providers/__init__.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/py.typed +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/screens/__init__.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/screens/login.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/widgets/__init__.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/widgets/_after_highlight.py +0 -0
- {oldnews-0.2.0 → oldnews-0.4.0}/src/oldnews/widgets/article_list.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oldnews
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "oldnews"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "A terminal-based client for TheOldReader"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -27,7 +27,7 @@ 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",
|
|
@@ -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
|
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
##############################################################################
|
|
4
4
|
# Local imports.
|
|
5
5
|
from .main import (
|
|
6
|
+
Copy,
|
|
7
|
+
CopyArticleToClipboard,
|
|
8
|
+
CopyFeedToClipboard,
|
|
9
|
+
CopyHomePageToClipboard,
|
|
6
10
|
Escape,
|
|
7
11
|
MarkAllRead,
|
|
8
12
|
Next,
|
|
9
13
|
NextUnread,
|
|
10
14
|
OpenArticle,
|
|
11
|
-
|
|
15
|
+
OpenHomePage,
|
|
12
16
|
Previous,
|
|
13
17
|
PreviousUnread,
|
|
14
18
|
RefreshFromTheOldReader,
|
|
@@ -18,12 +22,16 @@ from .main import (
|
|
|
18
22
|
##############################################################################
|
|
19
23
|
# Exports.
|
|
20
24
|
__all__ = [
|
|
25
|
+
"Copy",
|
|
26
|
+
"CopyArticleToClipboard",
|
|
27
|
+
"CopyFeedToClipboard",
|
|
28
|
+
"CopyHomePageToClipboard",
|
|
21
29
|
"Escape",
|
|
22
30
|
"MarkAllRead",
|
|
23
31
|
"Next",
|
|
24
32
|
"NextUnread",
|
|
25
33
|
"OpenArticle",
|
|
26
|
-
"
|
|
34
|
+
"OpenHomePage",
|
|
27
35
|
"Previous",
|
|
28
36
|
"PreviousUnread",
|
|
29
37
|
"RefreshFromTheOldReader",
|
|
@@ -64,8 +64,8 @@ class OpenArticle(Command):
|
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
##############################################################################
|
|
67
|
-
class
|
|
68
|
-
"""Open the
|
|
67
|
+
class OpenHomePage(Command):
|
|
68
|
+
"""Open the home page for the current subscription in the web browser"""
|
|
69
69
|
|
|
70
70
|
BINDING_KEY = "O"
|
|
71
71
|
|
|
@@ -77,4 +77,32 @@ class MarkAllRead(Command):
|
|
|
77
77
|
BINDING_KEY = "R"
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
##############################################################################
|
|
81
|
+
class CopyHomePageToClipboard(Command):
|
|
82
|
+
"""Copy the URL of the current subscription's home page to the clipboard"""
|
|
83
|
+
|
|
84
|
+
BINDING_KEY = "f3"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
##############################################################################
|
|
88
|
+
class CopyFeedToClipboard(Command):
|
|
89
|
+
"""Copy the URL of the current subscription's feed to the clipboard"""
|
|
90
|
+
|
|
91
|
+
BINDING_KEY = "f4"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
##############################################################################
|
|
95
|
+
class CopyArticleToClipboard(Command):
|
|
96
|
+
"""Copy the URL for the current article to the clipboard"""
|
|
97
|
+
|
|
98
|
+
BINDING_KEY = "f5"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
##############################################################################
|
|
102
|
+
class Copy(Command):
|
|
103
|
+
"""Copy a URL to the clipboard depending on the context"""
|
|
104
|
+
|
|
105
|
+
BINDING_KEY = "ctrl+c"
|
|
106
|
+
|
|
107
|
+
|
|
80
108
|
### main.py ends here
|
|
@@ -23,6 +23,7 @@ from .local_folders import get_local_folders, save_local_folders
|
|
|
23
23
|
from .local_subscriptions import get_local_subscriptions, save_local_subscriptions
|
|
24
24
|
from .local_unread import LocalUnread, get_local_unread, total_unread
|
|
25
25
|
from .navigation_state import get_navigation_state, save_navigation_state
|
|
26
|
+
from .reset import reset_data
|
|
26
27
|
|
|
27
28
|
##############################################################################
|
|
28
29
|
# Exports.
|
|
@@ -43,6 +44,7 @@ __all__ = [
|
|
|
43
44
|
"locally_mark_article_ids_read",
|
|
44
45
|
"LocalUnread",
|
|
45
46
|
"remember_we_last_grabbed_at",
|
|
47
|
+
"reset_data",
|
|
46
48
|
"save_configuration",
|
|
47
49
|
"save_local_articles",
|
|
48
50
|
"save_local_folders",
|
|
@@ -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
|
|
@@ -13,12 +13,16 @@ from textual_enhanced.commands import (
|
|
|
13
13
|
##############################################################################
|
|
14
14
|
# Local imports.
|
|
15
15
|
from ..commands import (
|
|
16
|
+
Copy,
|
|
17
|
+
CopyArticleToClipboard,
|
|
18
|
+
CopyFeedToClipboard,
|
|
19
|
+
CopyHomePageToClipboard,
|
|
16
20
|
Escape,
|
|
17
21
|
MarkAllRead,
|
|
18
22
|
Next,
|
|
19
23
|
NextUnread,
|
|
20
24
|
OpenArticle,
|
|
21
|
-
|
|
25
|
+
OpenHomePage,
|
|
22
26
|
Previous,
|
|
23
27
|
PreviousUnread,
|
|
24
28
|
RefreshFromTheOldReader,
|
|
@@ -42,8 +46,12 @@ class MainCommands(CommandsProvider):
|
|
|
42
46
|
yield from self.maybe(Previous)
|
|
43
47
|
yield from self.maybe(PreviousUnread)
|
|
44
48
|
yield from self.maybe(OpenArticle)
|
|
45
|
-
yield from self.maybe(
|
|
49
|
+
yield from self.maybe(OpenHomePage)
|
|
46
50
|
yield from self.maybe(MarkAllRead)
|
|
51
|
+
yield from self.maybe(CopyHomePageToClipboard)
|
|
52
|
+
yield from self.maybe(CopyFeedToClipboard)
|
|
53
|
+
yield from self.maybe(CopyArticleToClipboard)
|
|
54
|
+
yield from self.maybe(Copy)
|
|
47
55
|
yield ToggleShowAll()
|
|
48
56
|
yield RefreshFromTheOldReader()
|
|
49
57
|
yield ChangeTheme()
|
|
@@ -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,
|
|
@@ -39,12 +38,16 @@ from textual_enhanced.screen import EnhancedScreen
|
|
|
39
38
|
# Local imports.
|
|
40
39
|
from .. import __version__
|
|
41
40
|
from ..commands import (
|
|
41
|
+
Copy,
|
|
42
|
+
CopyArticleToClipboard,
|
|
43
|
+
CopyFeedToClipboard,
|
|
44
|
+
CopyHomePageToClipboard,
|
|
42
45
|
Escape,
|
|
43
46
|
MarkAllRead,
|
|
44
47
|
Next,
|
|
45
48
|
NextUnread,
|
|
46
49
|
OpenArticle,
|
|
47
|
-
|
|
50
|
+
OpenHomePage,
|
|
48
51
|
Previous,
|
|
49
52
|
PreviousUnread,
|
|
50
53
|
RefreshFromTheOldReader,
|
|
@@ -57,19 +60,15 @@ from ..data import (
|
|
|
57
60
|
get_local_folders,
|
|
58
61
|
get_local_subscriptions,
|
|
59
62
|
get_local_unread,
|
|
60
|
-
get_unread_article_ids,
|
|
61
63
|
last_grabbed_data_at,
|
|
62
64
|
load_configuration,
|
|
63
65
|
locally_mark_article_ids_read,
|
|
64
66
|
locally_mark_read,
|
|
65
|
-
remember_we_last_grabbed_at,
|
|
66
|
-
save_local_articles,
|
|
67
|
-
save_local_folders,
|
|
68
|
-
save_local_subscriptions,
|
|
69
67
|
total_unread,
|
|
70
68
|
update_configuration,
|
|
71
69
|
)
|
|
72
70
|
from ..providers import MainCommands
|
|
71
|
+
from ..sync import ToRSync
|
|
73
72
|
from ..widgets import ArticleContent, ArticleList, Navigation
|
|
74
73
|
|
|
75
74
|
|
|
@@ -122,6 +121,13 @@ class Main(EnhancedScreen[None]):
|
|
|
122
121
|
width: 25%;
|
|
123
122
|
}
|
|
124
123
|
|
|
124
|
+
#article-view {
|
|
125
|
+
display: none;
|
|
126
|
+
&.--has-articles {
|
|
127
|
+
display: block;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
125
131
|
ArticleList {
|
|
126
132
|
height: 1fr;
|
|
127
133
|
}
|
|
@@ -147,8 +153,12 @@ class Main(EnhancedScreen[None]):
|
|
|
147
153
|
Previous,
|
|
148
154
|
PreviousUnread,
|
|
149
155
|
OpenArticle,
|
|
150
|
-
|
|
156
|
+
OpenHomePage,
|
|
151
157
|
ChangeTheme,
|
|
158
|
+
CopyHomePageToClipboard,
|
|
159
|
+
CopyFeedToClipboard,
|
|
160
|
+
CopyArticleToClipboard,
|
|
161
|
+
Copy,
|
|
152
162
|
]
|
|
153
163
|
|
|
154
164
|
BINDINGS = Command.bindings(*COMMAND_MESSAGES)
|
|
@@ -198,6 +208,9 @@ class Main(EnhancedScreen[None]):
|
|
|
198
208
|
counts: LocalUnread
|
|
199
209
|
"""The new unread counts."""
|
|
200
210
|
|
|
211
|
+
class SyncFinished(Message):
|
|
212
|
+
"""Message sent when a sync from TheOldReader is finished."""
|
|
213
|
+
|
|
201
214
|
def __init__(self, session: Session) -> None:
|
|
202
215
|
"""Initialise the main screen."""
|
|
203
216
|
super().__init__()
|
|
@@ -210,7 +223,7 @@ class Main(EnhancedScreen[None]):
|
|
|
210
223
|
yield Navigation(classes="panel").data_bind(
|
|
211
224
|
Main.folders, Main.subscriptions, Main.unread
|
|
212
225
|
)
|
|
213
|
-
with Vertical():
|
|
226
|
+
with Vertical(id="article-view"):
|
|
214
227
|
yield ArticleList(classes="panel").data_bind(
|
|
215
228
|
Main.articles, Main.current_category
|
|
216
229
|
)
|
|
@@ -239,8 +252,14 @@ class Main(EnhancedScreen[None]):
|
|
|
239
252
|
# but okay let's be defensive... (when I can come up with a nice
|
|
240
253
|
# little MRE I'll report it).
|
|
241
254
|
return True
|
|
242
|
-
if action in (OpenArticle.action_name(),
|
|
255
|
+
if action in (OpenArticle.action_name(), CopyArticleToClipboard.action_name()):
|
|
243
256
|
return self.article is not None
|
|
257
|
+
if action in (
|
|
258
|
+
OpenHomePage.action_name(),
|
|
259
|
+
CopyFeedToClipboard.action_name(),
|
|
260
|
+
CopyHomePageToClipboard.action_name(),
|
|
261
|
+
):
|
|
262
|
+
return self.query_one(Navigation).current_subscription is not None
|
|
244
263
|
if action in (Next.action_name(), Previous.action_name()):
|
|
245
264
|
return self.articles is not None
|
|
246
265
|
if action in (
|
|
@@ -257,6 +276,11 @@ class Main(EnhancedScreen[None]):
|
|
|
257
276
|
return self.articles is not None and any(
|
|
258
277
|
article.is_unread for article in self.articles
|
|
259
278
|
)
|
|
279
|
+
if action == Copy.action_name():
|
|
280
|
+
return (
|
|
281
|
+
(navigation := self.query_one(Navigation)).has_focus
|
|
282
|
+
and navigation.current_subscription is not None
|
|
283
|
+
) or self.query_one("#article-view").has_focus_within
|
|
260
284
|
return True
|
|
261
285
|
|
|
262
286
|
@on(SubTitle)
|
|
@@ -298,6 +322,18 @@ class Main(EnhancedScreen[None]):
|
|
|
298
322
|
self.unread = message.counts
|
|
299
323
|
self.post_message(self.SubTitle(""))
|
|
300
324
|
|
|
325
|
+
def _refresh_article_list(self) -> None:
|
|
326
|
+
"""Refresh the content of the article list."""
|
|
327
|
+
if self.current_category:
|
|
328
|
+
self.articles = get_local_articles(self.current_category, not self.show_all)
|
|
329
|
+
# If the result is there's nothing showing, tidy up the content
|
|
330
|
+
# side of the display and maybe move focus back to navigation.
|
|
331
|
+
if not self.articles:
|
|
332
|
+
self.article = None
|
|
333
|
+
if self.query_one("#article-view").has_focus_within:
|
|
334
|
+
self.query_one(Navigation).focus()
|
|
335
|
+
self.query_one("#article-view").set_class(bool(self.articles), "--has-articles")
|
|
336
|
+
|
|
301
337
|
@work(thread=True, exclusive=True)
|
|
302
338
|
def _load_locally(self) -> None:
|
|
303
339
|
"""Load up any locally-held data."""
|
|
@@ -311,6 +347,7 @@ class Main(EnhancedScreen[None]):
|
|
|
311
347
|
self.notify(f"Old read articles cleaned from local storage: {cleaned}")
|
|
312
348
|
if unread := get_local_unread(folders, subscriptions):
|
|
313
349
|
self.post_message(self.NewUnread(unread))
|
|
350
|
+
self._refresh_article_list()
|
|
314
351
|
# If we've never grabbed data from ToR before, or if it's been long enough...
|
|
315
352
|
if (last_grabbed := last_grabbed_data_at()) is None or (
|
|
316
353
|
(datetime.now() - last_grabbed).seconds
|
|
@@ -319,95 +356,27 @@ class Main(EnhancedScreen[None]):
|
|
|
319
356
|
# ...kick off a refresh from TheOldReader.
|
|
320
357
|
self.post_message(RefreshFromTheOldReader())
|
|
321
358
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
)
|
|
327
|
-
new_grab = datetime.now(timezone.utc)
|
|
328
|
-
loaded = 0
|
|
329
|
-
async for article in Articles.stream_new_since(
|
|
330
|
-
self._session, last_grabbed, n=10
|
|
331
|
-
):
|
|
332
|
-
# I've encountered articles that don't have an origin stream ID,
|
|
333
|
-
# which means that I can't relate them back to a stream, which
|
|
334
|
-
# means I'll never see them anyway...
|
|
335
|
-
if not article.origin.stream_id:
|
|
336
|
-
continue
|
|
337
|
-
# TODO: Right now I'm saving articles one at a time; perhaps I
|
|
338
|
-
# should save them in small batches? This would be simple enough
|
|
339
|
-
# -- perhaps same them in batches the same size as the buffer
|
|
340
|
-
# window I'm using right now (currently 10 articles per trip to
|
|
341
|
-
# ToR).
|
|
342
|
-
save_local_articles(Articles([article]))
|
|
343
|
-
loaded += 1
|
|
344
|
-
if (loaded % 10) == 0:
|
|
345
|
-
self.post_message(
|
|
346
|
-
self.SubTitle(f"Downloading articles from TheOldReader: {loaded}")
|
|
347
|
-
)
|
|
348
|
-
if loaded:
|
|
349
|
-
self.notify(f"Articles downloaded: {loaded}")
|
|
350
|
-
else:
|
|
351
|
-
self.notify("No new articles found on TheOldReader")
|
|
352
|
-
remember_we_last_grabbed_at(new_grab)
|
|
353
|
-
|
|
354
|
-
async def _refresh_read_status(self) -> None:
|
|
355
|
-
"""Refresh the read status from the server."""
|
|
356
|
-
self.post_message(
|
|
357
|
-
self.SubTitle("Getting list of unread articles from TheOldReader")
|
|
358
|
-
)
|
|
359
|
-
remote_unread_articles = set(
|
|
360
|
-
article_id.full_id
|
|
361
|
-
for article_id in await ArticleIDs.load_unread(self._session)
|
|
362
|
-
)
|
|
363
|
-
self.post_message(self.SubTitle("Comparing against locally-read articles"))
|
|
364
|
-
local_unread_articles = set(get_unread_article_ids())
|
|
365
|
-
if mark_as_read := local_unread_articles - remote_unread_articles:
|
|
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
|
-
)
|
|
359
|
+
@on(SyncFinished)
|
|
360
|
+
def _sync_finished(self) -> None:
|
|
361
|
+
"""Clean up after a sync from TheOldReader has finished."""
|
|
362
|
+
self._refresh_article_list()
|
|
363
|
+
self.post_message(self.SubTitle(""))
|
|
370
364
|
|
|
371
365
|
@on(RefreshFromTheOldReader)
|
|
372
366
|
@work(exclusive=True)
|
|
373
367
|
async def action_refresh_from_the_old_reader_command(self) -> None:
|
|
374
368
|
"""Load the main data from TheOldReader."""
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
self.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
save_local_subscriptions(await Subscriptions.load(self._session))
|
|
387
|
-
)
|
|
388
|
-
)
|
|
389
|
-
|
|
390
|
-
# Download the latest articles we don't know about.
|
|
391
|
-
if never_grabbed_before := last_grabbed_data_at() is None:
|
|
392
|
-
self.post_message(self.SubTitle("Getting available articles"))
|
|
393
|
-
else:
|
|
394
|
-
self.post_message(
|
|
395
|
-
self.SubTitle(f"Getting articles new since {last_grabbed_data_at()}")
|
|
396
|
-
)
|
|
397
|
-
await self._download_newest_articles()
|
|
398
|
-
|
|
399
|
-
# If we have grabbed data before, let's try and sync up what's been read.
|
|
400
|
-
if not never_grabbed_before:
|
|
401
|
-
await self._refresh_read_status()
|
|
402
|
-
|
|
403
|
-
# Recalculate the unread counts.
|
|
404
|
-
self.post_message(self.SubTitle("Calculating unread counts"))
|
|
405
|
-
self.post_message(
|
|
406
|
-
self.NewUnread(get_local_unread(self.folders, self.subscriptions))
|
|
407
|
-
)
|
|
408
|
-
|
|
409
|
-
# Finally we're all done.
|
|
410
|
-
self.post_message(self.SubTitle(""))
|
|
369
|
+
await ToRSync(
|
|
370
|
+
self._session,
|
|
371
|
+
on_new_step=lambda step: self.post_message(self.SubTitle(step)),
|
|
372
|
+
on_new_result=lambda result: self.notify(result),
|
|
373
|
+
on_new_folders=lambda folders: self.post_message(self.NewFolders(folders)),
|
|
374
|
+
on_new_subscriptions=lambda subscriptions: self.post_message(
|
|
375
|
+
self.NewSubscriptions(subscriptions)
|
|
376
|
+
),
|
|
377
|
+
on_new_unread=lambda unread: self.post_message(self.NewUnread(unread)),
|
|
378
|
+
on_sync_finished=lambda: self.post_message(self.SyncFinished()),
|
|
379
|
+
).refresh()
|
|
411
380
|
|
|
412
381
|
@on(Navigation.CategorySelected)
|
|
413
382
|
def _handle_navigaion_selection(self, message: Navigation.CategorySelected) -> None:
|
|
@@ -418,14 +387,9 @@ class Main(EnhancedScreen[None]):
|
|
|
418
387
|
"""
|
|
419
388
|
self.current_category = message.category
|
|
420
389
|
self.article = None
|
|
421
|
-
self.
|
|
390
|
+
self._refresh_article_list()
|
|
422
391
|
self.query_one(ArticleList).focus()
|
|
423
392
|
|
|
424
|
-
def _refresh_article_list(self) -> None:
|
|
425
|
-
"""Refresh the content of the article list."""
|
|
426
|
-
if category := self.query_one(Navigation).current_category:
|
|
427
|
-
self.articles = get_local_articles(category, not self.show_all)
|
|
428
|
-
|
|
429
393
|
def _watch_show_all(self) -> None:
|
|
430
394
|
"""Handle changes to the show all flag."""
|
|
431
395
|
self._refresh_article_list()
|
|
@@ -563,17 +527,62 @@ class Main(EnhancedScreen[None]):
|
|
|
563
527
|
severity="error",
|
|
564
528
|
)
|
|
565
529
|
|
|
566
|
-
def
|
|
567
|
-
"""Open the
|
|
568
|
-
if self.
|
|
569
|
-
if
|
|
570
|
-
open_url(
|
|
530
|
+
def action_open_home_page_command(self) -> None:
|
|
531
|
+
"""Open the home page of the current subscription in the web browser."""
|
|
532
|
+
if subscription := self.query_one(Navigation).current_subscription:
|
|
533
|
+
if subscription.html_url:
|
|
534
|
+
open_url(subscription.html_url)
|
|
571
535
|
else:
|
|
572
536
|
self.notify(
|
|
573
|
-
"No URL available for the
|
|
537
|
+
"No home page URL available for the subscription",
|
|
574
538
|
severity="error",
|
|
575
539
|
title="Can't visit",
|
|
576
540
|
)
|
|
577
541
|
|
|
542
|
+
def _copy_to_clipboard(self, content: str | None, empty_error: str) -> None:
|
|
543
|
+
"""Copy some content to the clipboard.
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
content: The content to copy to the clipboard.
|
|
547
|
+
empty_error: The message to show if there's no content.
|
|
548
|
+
"""
|
|
549
|
+
if content:
|
|
550
|
+
self.app.copy_to_clipboard(content)
|
|
551
|
+
self.notify("Copied to clipboard")
|
|
552
|
+
else:
|
|
553
|
+
self.notify(empty_error, severity="error", title="Can't copy")
|
|
554
|
+
|
|
555
|
+
def action_copy_home_page_to_clipboard_command(self) -> None:
|
|
556
|
+
"""Copy the URL of the current subscription's homepage to the clipboard."""
|
|
557
|
+
if subscription := self.query_one(Navigation).current_subscription:
|
|
558
|
+
self._copy_to_clipboard(
|
|
559
|
+
subscription.html_url, "No home page URL available for the subscription"
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
def action_copy_feed_to_clipboard_command(self) -> None:
|
|
563
|
+
"""Copy the URL of the current subscription's feed to the clipboard."""
|
|
564
|
+
if subscription := self.query_one(Navigation).current_subscription:
|
|
565
|
+
self._copy_to_clipboard(
|
|
566
|
+
subscription.url, "No feed URL available for the subscription"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def action_copy_article_to_clipboard_command(self) -> None:
|
|
570
|
+
"""Copy the URL of the current article to the clipboard."""
|
|
571
|
+
if self.article:
|
|
572
|
+
self._copy_to_clipboard(
|
|
573
|
+
self.article.html_url, "No URL available for the article"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
def action_copy_command(self) -> None:
|
|
577
|
+
"""Copy a URL to the clipboard depending on the current context."""
|
|
578
|
+
if (navigation := self.query_one(Navigation)).has_focus:
|
|
579
|
+
if navigation.current_subscription:
|
|
580
|
+
self.action_copy_home_page_to_clipboard_command()
|
|
581
|
+
elif self.query_one("#article-view").has_focus_within:
|
|
582
|
+
if self.article:
|
|
583
|
+
self.action_copy_article_to_clipboard_command()
|
|
584
|
+
else:
|
|
585
|
+
self.action_copy_home_page_to_clipboard_command()
|
|
586
|
+
|
|
578
587
|
|
|
579
588
|
### main.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
|
+
new_grab = datetime.now(timezone.utc)
|
|
75
|
+
last_grabbed = last_grabbed_data_at() or (
|
|
76
|
+
new_grab - timedelta(days=load_configuration().local_history)
|
|
77
|
+
)
|
|
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
|
|
@@ -17,7 +17,7 @@ from oldas import Article
|
|
|
17
17
|
from textual.app import ComposeResult
|
|
18
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,6 +32,10 @@ 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;
|
|
@@ -49,6 +53,15 @@ class ArticleContent(Vertical):
|
|
|
49
53
|
}
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
&:focus-within {
|
|
57
|
+
#header, Rule {
|
|
58
|
+
background: $boost;
|
|
59
|
+
}
|
|
60
|
+
Rule {
|
|
61
|
+
color: $border;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
52
65
|
Markdown {
|
|
53
66
|
padding: 0 1 0 1;
|
|
54
67
|
}
|
|
@@ -67,11 +80,13 @@ class ArticleContent(Vertical):
|
|
|
67
80
|
|
|
68
81
|
def compose(self) -> ComposeResult:
|
|
69
82
|
"""Compose the content of the widget."""
|
|
83
|
+
yield Rule()
|
|
70
84
|
with Vertical(id="header"):
|
|
71
85
|
with Horizontal():
|
|
72
86
|
yield Label(id="title", markup=False)
|
|
73
87
|
yield Label(id="published")
|
|
74
88
|
yield Label(id="link", markup=False)
|
|
89
|
+
yield Rule()
|
|
75
90
|
with VerticalScroll():
|
|
76
91
|
yield Markdown()
|
|
77
92
|
|
|
@@ -87,7 +102,7 @@ class ArticleContent(Vertical):
|
|
|
87
102
|
link.visible = True
|
|
88
103
|
link.update(self.article.html_url)
|
|
89
104
|
await self.query_one(Markdown).update(convert(self.article.summary.content))
|
|
90
|
-
self.query_one(VerticalScroll).scroll_home()
|
|
105
|
+
self.query_one(VerticalScroll).scroll_home(animate=False)
|
|
91
106
|
self.set_class(self.article is not None, "--has-article")
|
|
92
107
|
|
|
93
108
|
def focus(self, scroll_visible: bool = True) -> Self:
|
|
@@ -238,6 +238,20 @@ class Navigation(EnhancedOptionList):
|
|
|
238
238
|
return selected.subscription
|
|
239
239
|
raise ValueError("Unknown category")
|
|
240
240
|
|
|
241
|
+
@property
|
|
242
|
+
def current_folder(self) -> Folder | None:
|
|
243
|
+
"""The current folder, if one is highlighted, or `None`"""
|
|
244
|
+
if isinstance(current := self.current_category, Folder):
|
|
245
|
+
return current
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def current_subscription(self) -> Subscription | None:
|
|
250
|
+
"""The current subscription, if one is highlighted, or `None`."""
|
|
251
|
+
if isinstance(current := self.current_category, Subscription):
|
|
252
|
+
return current
|
|
253
|
+
return None
|
|
254
|
+
|
|
241
255
|
def _highlight_unread(self, direction: HighlightDirection) -> bool:
|
|
242
256
|
"""Highlight the next category with unread articles, if there is one.
|
|
243
257
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|