oldnews 0.5.0__py3-none-any.whl → 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oldnews/__main__.py +11 -0
- oldnews/data/__init__.py +4 -0
- oldnews/data/dump.py +75 -0
- oldnews/data/local_articles.py +15 -5
- oldnews/data/log.py +38 -0
- oldnews/providers/main.py +14 -14
- oldnews/screens/login.py +7 -3
- oldnews/screens/main.py +14 -48
- oldnews/screens/new_subscription.py +11 -5
- oldnews/sync.py +129 -56
- oldnews/widgets/article_content.py +20 -9
- oldnews/widgets/navigation.py +19 -1
- {oldnews-0.5.0.dist-info → oldnews-0.6.0.dist-info}/METADATA +1 -1
- {oldnews-0.5.0.dist-info → oldnews-0.6.0.dist-info}/RECORD +16 -14
- {oldnews-0.5.0.dist-info → oldnews-0.6.0.dist-info}/WHEEL +0 -0
- {oldnews-0.5.0.dist-info → oldnews-0.6.0.dist-info}/entry_points.txt +0 -0
oldnews/__main__.py
CHANGED
|
@@ -10,6 +10,7 @@ from operator import attrgetter
|
|
|
10
10
|
# Local imports.
|
|
11
11
|
from . import __doc__, __version__
|
|
12
12
|
from .data import initialise_database, reset_data
|
|
13
|
+
from .data.locations import config_dir, data_dir
|
|
13
14
|
from .oldnews import OldNews
|
|
14
15
|
|
|
15
16
|
|
|
@@ -49,6 +50,13 @@ def get_args() -> Namespace:
|
|
|
49
50
|
dest="command", help="Available commands", required=False
|
|
50
51
|
)
|
|
51
52
|
|
|
53
|
+
# Add the 'directories' command.
|
|
54
|
+
sub_parser.add_parser(
|
|
55
|
+
"directories",
|
|
56
|
+
aliases=["dirs", "d"],
|
|
57
|
+
help="Show the directories created and used by OldNews",
|
|
58
|
+
)
|
|
59
|
+
|
|
52
60
|
# Add the 'license' command.
|
|
53
61
|
sub_parser.add_parser(
|
|
54
62
|
"license",
|
|
@@ -135,6 +143,9 @@ def reset_news(args: Namespace) -> None:
|
|
|
135
143
|
def main() -> None:
|
|
136
144
|
"""Main entry function."""
|
|
137
145
|
match (args := get_args()).command:
|
|
146
|
+
case "d" | "dirs" | "directories":
|
|
147
|
+
print(config_dir())
|
|
148
|
+
print(data_dir())
|
|
138
149
|
case "reset":
|
|
139
150
|
reset_news(args)
|
|
140
151
|
case "license" | "licence":
|
oldnews/data/__init__.py
CHANGED
|
@@ -10,6 +10,7 @@ from .config import (
|
|
|
10
10
|
update_configuration,
|
|
11
11
|
)
|
|
12
12
|
from .db import initialise_database
|
|
13
|
+
from .dump import data_dump
|
|
13
14
|
from .last_grab import last_grabbed_data_at, remember_we_last_grabbed_at
|
|
14
15
|
from .local_articles import (
|
|
15
16
|
clean_old_read_articles,
|
|
@@ -26,6 +27,7 @@ from .local_articles import (
|
|
|
26
27
|
from .local_folders import get_local_folders, save_local_folders
|
|
27
28
|
from .local_subscriptions import get_local_subscriptions, save_local_subscriptions
|
|
28
29
|
from .local_unread import LocalUnread, get_local_unread, total_unread
|
|
30
|
+
from .log import Log
|
|
29
31
|
from .navigation_state import get_navigation_state, save_navigation_state
|
|
30
32
|
from .reset import reset_data
|
|
31
33
|
|
|
@@ -33,7 +35,9 @@ from .reset import reset_data
|
|
|
33
35
|
# Exports.
|
|
34
36
|
__all__ = [
|
|
35
37
|
"Configuration",
|
|
38
|
+
"Log",
|
|
36
39
|
"clean_old_read_articles",
|
|
40
|
+
"data_dump",
|
|
37
41
|
"get_auth_token",
|
|
38
42
|
"get_local_articles",
|
|
39
43
|
"get_local_folders",
|
oldnews/data/dump.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Provides tools to dump data into an easy-to-browse format."""
|
|
2
|
+
|
|
3
|
+
##############################################################################
|
|
4
|
+
# Python imports.
|
|
5
|
+
from functools import singledispatch
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
##############################################################################
|
|
9
|
+
# OldAS imports.
|
|
10
|
+
from oldas import Article, Folder, Subscription
|
|
11
|
+
|
|
12
|
+
##############################################################################
|
|
13
|
+
type DataDump = tuple[tuple[str, str], ...]
|
|
14
|
+
"""Type of a data dump."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
##############################################################################
|
|
18
|
+
@singledispatch
|
|
19
|
+
def data_dump(data: Any) -> DataDump:
|
|
20
|
+
"""Dump the given data into an easy-to-browse format.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
data: The data to dump.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
A `DataDump` of the data.
|
|
27
|
+
"""
|
|
28
|
+
return (("Data", str(data)),)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
##############################################################################
|
|
32
|
+
@data_dump.register
|
|
33
|
+
def _(data: Folder) -> DataDump:
|
|
34
|
+
return (("ID", data.id), ("Sort ID", data.sort_id))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
##############################################################################
|
|
38
|
+
@data_dump.register
|
|
39
|
+
def _(data: Subscription) -> DataDump:
|
|
40
|
+
return (
|
|
41
|
+
("ID", data.id),
|
|
42
|
+
("Title", data.title),
|
|
43
|
+
("Sort ID", data.sort_id),
|
|
44
|
+
("First Item Time", f"{data.first_item_time}"),
|
|
45
|
+
("URL", data.url),
|
|
46
|
+
("HTML URL", data.html_url),
|
|
47
|
+
*(
|
|
48
|
+
(
|
|
49
|
+
f"Category[{n}]",
|
|
50
|
+
f"{category.id}, {category.label}",
|
|
51
|
+
)
|
|
52
|
+
for n, category in enumerate(data.categories)
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
##############################################################################
|
|
58
|
+
@data_dump.register
|
|
59
|
+
def _(data: Article) -> DataDump:
|
|
60
|
+
# TODO: The article has pretty rich data, so in here I'm not showing
|
|
61
|
+
# it all, just enough to be useful. In the future perhaps make it a
|
|
62
|
+
# lot richer.
|
|
63
|
+
return (
|
|
64
|
+
("ID", data.id),
|
|
65
|
+
("Title", data.title),
|
|
66
|
+
("Published", f"{data.published}"),
|
|
67
|
+
("Updated", f"{data.updated}"),
|
|
68
|
+
*(
|
|
69
|
+
(f"Category[{n}]", f"{category}")
|
|
70
|
+
for n, category in enumerate(data.categories)
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
### dump.py ends here
|
oldnews/data/local_articles.py
CHANGED
|
@@ -17,6 +17,7 @@ from typedal import TypedField, TypedTable, relationship
|
|
|
17
17
|
|
|
18
18
|
##############################################################################
|
|
19
19
|
# Local imports.
|
|
20
|
+
from .log import Log
|
|
20
21
|
from .tools import commit
|
|
21
22
|
|
|
22
23
|
|
|
@@ -339,11 +340,13 @@ def clean_old_read_articles(cutoff: timedelta) -> int:
|
|
|
339
340
|
"""Clean up articles that are older than the given cutoff time."""
|
|
340
341
|
read = get_local_read_article_ids()
|
|
341
342
|
retire_time = datetime.now() - cutoff
|
|
343
|
+
Log().debug(f"Cleaning up read articles published before {retire_time}")
|
|
342
344
|
cleaned = len(
|
|
343
345
|
LocalArticle.where(
|
|
344
346
|
(LocalArticle.published < retire_time) & LocalArticle.id.belongs(read)
|
|
345
347
|
).delete()
|
|
346
348
|
)
|
|
349
|
+
Log().debug(f"Cleaned: {cleaned}")
|
|
347
350
|
commit(LocalArticle)
|
|
348
351
|
return cleaned
|
|
349
352
|
|
|
@@ -358,6 +361,7 @@ def rename_folder_for_articles(rename_from: str | Folder, rename_to: str) -> Non
|
|
|
358
361
|
"""
|
|
359
362
|
rename_from = Folders.full_id(rename_from)
|
|
360
363
|
rename_to = Folders.full_id(rename_to)
|
|
364
|
+
Log().debug(f"Renaming folder for local articles from {rename_from} to {rename_to}")
|
|
361
365
|
LocalArticleCategory.where(LocalArticleCategory.category == rename_from).update(
|
|
362
366
|
category=rename_to
|
|
363
367
|
)
|
|
@@ -371,9 +375,9 @@ def remove_folder_from_articles(folder: str | Folder) -> None:
|
|
|
371
375
|
Args:
|
|
372
376
|
folder: The folder to remove from all articles.
|
|
373
377
|
"""
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
).delete()
|
|
378
|
+
folder = Folders.full_id(folder)
|
|
379
|
+
Log().debug(f"Removing folder {folder} from all local articles")
|
|
380
|
+
LocalArticleCategory.where(LocalArticleCategory.category == folder).delete()
|
|
377
381
|
commit(LocalArticleCategory)
|
|
378
382
|
|
|
379
383
|
|
|
@@ -394,6 +398,9 @@ def move_subscription_articles(
|
|
|
394
398
|
Folders.full_id(from_folder) if from_folder is not None else from_folder
|
|
395
399
|
)
|
|
396
400
|
to_folder = Folders.full_id(to_folder) if to_folder is not None else to_folder
|
|
401
|
+
Log().debug(
|
|
402
|
+
f"Moving all articles of {subscription.title} ({subscription.id}) from folder {from_folder} to {to_folder}"
|
|
403
|
+
)
|
|
397
404
|
for article in LocalArticle.where(origin_stream_id=subscription.id).join().select():
|
|
398
405
|
if from_folder:
|
|
399
406
|
LocalArticleCategory.where(
|
|
@@ -407,13 +414,16 @@ def move_subscription_articles(
|
|
|
407
414
|
|
|
408
415
|
|
|
409
416
|
##############################################################################
|
|
410
|
-
def remove_subscription_articles(subscription: Subscription) -> None:
|
|
417
|
+
def remove_subscription_articles(subscription: str | Subscription) -> None:
|
|
411
418
|
"""Remove all the articles associated with the given subscription.
|
|
412
419
|
|
|
413
420
|
Args:
|
|
414
421
|
subscription: The subscription to remove the articles for.
|
|
415
422
|
"""
|
|
416
|
-
|
|
423
|
+
if isinstance(subscription, Subscription):
|
|
424
|
+
subscription = subscription.id
|
|
425
|
+
Log().debug(f"Removing all local articles for subscription {subscription}")
|
|
426
|
+
LocalArticle.where(origin_stream_id=subscription).delete()
|
|
417
427
|
commit(LocalArticle)
|
|
418
428
|
|
|
419
429
|
|
oldnews/data/log.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Provides the application's logger."""
|
|
2
|
+
|
|
3
|
+
##############################################################################
|
|
4
|
+
# Python imports.
|
|
5
|
+
from functools import cache
|
|
6
|
+
from logging import DEBUG, INFO, Formatter, Logger, getLogger
|
|
7
|
+
from logging.handlers import RotatingFileHandler
|
|
8
|
+
from os import getenv
|
|
9
|
+
|
|
10
|
+
##############################################################################
|
|
11
|
+
# Local imports.
|
|
12
|
+
from .locations import data_dir
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
##############################################################################
|
|
16
|
+
def _build_logger() -> Logger:
|
|
17
|
+
"""Build a logger for the application.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
A configured `Logger` object.
|
|
21
|
+
"""
|
|
22
|
+
logger = getLogger("oldnews")
|
|
23
|
+
logger.setLevel(DEBUG if getenv("OLDNEWS_DEBUG") else INFO)
|
|
24
|
+
file_handler = RotatingFileHandler(
|
|
25
|
+
data_dir() / "oldnews.log", maxBytes=1024 * 1024, backupCount=5
|
|
26
|
+
)
|
|
27
|
+
file_handler.setFormatter(
|
|
28
|
+
Formatter("%(asctime)s - %(levelname)s - %(module)s:%(lineno)d - %(message)s")
|
|
29
|
+
)
|
|
30
|
+
logger.addHandler(file_handler)
|
|
31
|
+
return logger
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
##############################################################################
|
|
35
|
+
Log = cache(_build_logger)
|
|
36
|
+
"""The application-wide logging object."""
|
|
37
|
+
|
|
38
|
+
### log.py ends here
|
oldnews/providers/main.py
CHANGED
|
@@ -45,28 +45,28 @@ class MainCommands(CommandsProvider):
|
|
|
45
45
|
Yields:
|
|
46
46
|
The commands for the command palette.
|
|
47
47
|
"""
|
|
48
|
+
yield AddSubscription()
|
|
49
|
+
yield ChangeTheme()
|
|
50
|
+
yield from self.maybe(Copy)
|
|
51
|
+
yield from self.maybe(CopyArticleToClipboard)
|
|
52
|
+
yield from self.maybe(CopyFeedToClipboard)
|
|
53
|
+
yield from self.maybe(CopyHomePageToClipboard)
|
|
48
54
|
yield Escape()
|
|
55
|
+
yield Help()
|
|
56
|
+
yield from self.maybe(Information)
|
|
57
|
+
yield from self.maybe(MarkAllRead)
|
|
58
|
+
yield from self.maybe(MoveSubscription)
|
|
49
59
|
yield from self.maybe(Next)
|
|
50
60
|
yield from self.maybe(NextUnread)
|
|
51
|
-
yield from self.maybe(Previous)
|
|
52
|
-
yield from self.maybe(PreviousUnread)
|
|
53
61
|
yield from self.maybe(OpenArticle)
|
|
54
62
|
yield from self.maybe(OpenHomePage)
|
|
55
|
-
yield from self.maybe(
|
|
56
|
-
yield from self.maybe(
|
|
57
|
-
yield
|
|
58
|
-
yield
|
|
59
|
-
yield from self.maybe(CopyArticleToClipboard)
|
|
60
|
-
yield from self.maybe(Copy)
|
|
63
|
+
yield from self.maybe(Previous)
|
|
64
|
+
yield from self.maybe(PreviousUnread)
|
|
65
|
+
yield Quit()
|
|
66
|
+
yield RefreshFromTheOldReader()
|
|
61
67
|
yield from self.maybe(Rename)
|
|
62
68
|
yield from self.maybe(Remove)
|
|
63
|
-
yield from self.maybe(Information)
|
|
64
|
-
yield AddSubscription()
|
|
65
69
|
yield ToggleShowAll()
|
|
66
|
-
yield RefreshFromTheOldReader()
|
|
67
|
-
yield ChangeTheme()
|
|
68
|
-
yield Help()
|
|
69
|
-
yield Quit()
|
|
70
70
|
|
|
71
71
|
|
|
72
72
|
### main.py ends here
|
oldnews/screens/login.py
CHANGED
|
@@ -9,6 +9,7 @@ from oldas import OldASError, OldASInvalidLogin, Session
|
|
|
9
9
|
from textual import on
|
|
10
10
|
from textual.app import ComposeResult
|
|
11
11
|
from textual.containers import Horizontal, Vertical
|
|
12
|
+
from textual.getters import query_one
|
|
12
13
|
from textual.screen import ModalScreen
|
|
13
14
|
from textual.widgets import Button, Input
|
|
14
15
|
|
|
@@ -48,6 +49,11 @@ class Login(ModalScreen[Session | None]):
|
|
|
48
49
|
|
|
49
50
|
BINDINGS = [("escape", "cancel")]
|
|
50
51
|
|
|
52
|
+
user_name = query_one("#user-name", Input)
|
|
53
|
+
"""The user name input widget."""
|
|
54
|
+
password = query_one("#password", Input)
|
|
55
|
+
"""The password input widget."""
|
|
56
|
+
|
|
51
57
|
def __init__(self, session: Session) -> None:
|
|
52
58
|
"""Initialise the login dialog.
|
|
53
59
|
|
|
@@ -71,9 +77,7 @@ class Login(ModalScreen[Session | None]):
|
|
|
71
77
|
@on(Button.Pressed, "#login")
|
|
72
78
|
async def login(self) -> None:
|
|
73
79
|
"""Log into TheOldReader."""
|
|
74
|
-
if (user := self.
|
|
75
|
-
password := self.query_one("#password", Input).value
|
|
76
|
-
):
|
|
80
|
+
if (user := self.user_name.value) and (password := self.password.value):
|
|
77
81
|
try:
|
|
78
82
|
# TODO: Add some sort of busy effect as the login happens.
|
|
79
83
|
await self._session.login(user, password)
|
oldnews/screens/main.py
CHANGED
|
@@ -62,6 +62,7 @@ from ..commands import (
|
|
|
62
62
|
from ..data import (
|
|
63
63
|
LocalUnread,
|
|
64
64
|
clean_old_read_articles,
|
|
65
|
+
data_dump,
|
|
65
66
|
get_local_articles,
|
|
66
67
|
get_local_folders,
|
|
67
68
|
get_local_subscriptions,
|
|
@@ -159,24 +160,24 @@ class Main(EnhancedScreen[None]):
|
|
|
159
160
|
Quit,
|
|
160
161
|
RefreshFromTheOldReader,
|
|
161
162
|
# Everything else.
|
|
163
|
+
AddSubscription,
|
|
164
|
+
ChangeTheme,
|
|
165
|
+
Copy,
|
|
166
|
+
CopyArticleToClipboard,
|
|
167
|
+
CopyFeedToClipboard,
|
|
168
|
+
CopyHomePageToClipboard,
|
|
162
169
|
Escape,
|
|
163
170
|
Information,
|
|
164
171
|
MarkAllRead,
|
|
172
|
+
MoveSubscription,
|
|
165
173
|
Next,
|
|
166
174
|
NextUnread,
|
|
167
|
-
Previous,
|
|
168
|
-
PreviousUnread,
|
|
169
175
|
OpenArticle,
|
|
170
176
|
OpenHomePage,
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
CopyFeedToClipboard,
|
|
174
|
-
CopyArticleToClipboard,
|
|
175
|
-
Copy,
|
|
176
|
-
AddSubscription,
|
|
177
|
-
Rename,
|
|
177
|
+
Previous,
|
|
178
|
+
PreviousUnread,
|
|
178
179
|
Remove,
|
|
179
|
-
|
|
180
|
+
Rename,
|
|
180
181
|
]
|
|
181
182
|
|
|
182
183
|
BINDINGS = Command.bindings(*COMMAND_MESSAGES)
|
|
@@ -767,48 +768,13 @@ class Main(EnhancedScreen[None]):
|
|
|
767
768
|
@work
|
|
768
769
|
async def action_information_command(self) -> None:
|
|
769
770
|
"""Show some information about the current item."""
|
|
770
|
-
# TODO: The article has pretty rich data, so in here I'm not showing
|
|
771
|
-
# it all, just enough to be useful. In the future perhaps make it a
|
|
772
|
-
# lot richer.
|
|
773
771
|
information: InformationDisplay | None = None
|
|
774
772
|
if self.navigation.has_focus and (category := self.navigation.current_category):
|
|
775
|
-
if isinstance(category, Folder):
|
|
776
|
-
information = InformationDisplay(
|
|
777
|
-
"Folder", (("ID", category.id), ("Sort ID", category.sort_id))
|
|
778
|
-
)
|
|
779
|
-
elif isinstance(category, Subscription):
|
|
780
|
-
information = InformationDisplay(
|
|
781
|
-
"Subscription",
|
|
782
|
-
(
|
|
783
|
-
("ID", category.id),
|
|
784
|
-
("Title", category.title),
|
|
785
|
-
("Sort ID", category.sort_id),
|
|
786
|
-
("First Item Time", f"{category.first_item_time}"),
|
|
787
|
-
("URL", category.url),
|
|
788
|
-
("HTML URL", category.html_url),
|
|
789
|
-
*(
|
|
790
|
-
(
|
|
791
|
-
f"Category[{n}]",
|
|
792
|
-
f"{sub_category.id}, {sub_category.label}",
|
|
793
|
-
)
|
|
794
|
-
for n, sub_category in enumerate(category.categories)
|
|
795
|
-
),
|
|
796
|
-
),
|
|
797
|
-
)
|
|
798
|
-
elif self.article_view.has_focus_within and self.article:
|
|
799
773
|
information = InformationDisplay(
|
|
800
|
-
|
|
801
|
-
(
|
|
802
|
-
("ID", self.article.id),
|
|
803
|
-
("Title", self.article.title),
|
|
804
|
-
("Published", f"{self.article.published}"),
|
|
805
|
-
("Updated", f"{self.article.updated}"),
|
|
806
|
-
*(
|
|
807
|
-
(f"Category[{n}]", f"{sub_category}")
|
|
808
|
-
for n, sub_category in enumerate(self.article.categories)
|
|
809
|
-
),
|
|
810
|
-
),
|
|
774
|
+
category.__class__.__name__, data_dump(category)
|
|
811
775
|
)
|
|
776
|
+
elif self.article_view.has_focus_within and self.article:
|
|
777
|
+
information = InformationDisplay("Article", data_dump(self.article))
|
|
812
778
|
if information:
|
|
813
779
|
await self.app.push_screen_wait(information)
|
|
814
780
|
|
|
@@ -13,6 +13,7 @@ from oldas import Folders
|
|
|
13
13
|
from textual import on
|
|
14
14
|
from textual.app import ComposeResult
|
|
15
15
|
from textual.containers import Horizontal, Vertical
|
|
16
|
+
from textual.getters import query_one
|
|
16
17
|
from textual.screen import ModalScreen
|
|
17
18
|
from textual.widgets import Button, Input, Label
|
|
18
19
|
|
|
@@ -66,6 +67,13 @@ class NewSubscription(ModalScreen[NewSubscriptionData | None]):
|
|
|
66
67
|
|
|
67
68
|
BINDINGS = [("escape", "cancel")]
|
|
68
69
|
|
|
70
|
+
feed_input = query_one("#feed", Input)
|
|
71
|
+
"""The feed input widget."""
|
|
72
|
+
folder_input = query_one("#folder", Input)
|
|
73
|
+
"""The folder input widget."""
|
|
74
|
+
add_button = query_one("#add", Button)
|
|
75
|
+
"""The add button."""
|
|
76
|
+
|
|
69
77
|
def __init__(self, folders: Folders) -> None:
|
|
70
78
|
"""Initialise the dialog object.
|
|
71
79
|
|
|
@@ -98,18 +106,16 @@ class NewSubscription(ModalScreen[NewSubscriptionData | None]):
|
|
|
98
106
|
@on(Input.Changed, "#feed")
|
|
99
107
|
def _refresh_state(self) -> None:
|
|
100
108
|
"""Refresh the state of the dialog."""
|
|
101
|
-
self.
|
|
102
|
-
self.query_one("#feed", Input).value.strip()
|
|
103
|
-
)
|
|
109
|
+
self.add_button.disabled = not bool(self.feed_input.value.strip())
|
|
104
110
|
|
|
105
111
|
@on(Button.Pressed, "#add")
|
|
106
112
|
def action_add(self) -> None:
|
|
107
113
|
"""React to the user pressing the add button."""
|
|
108
|
-
if feed := self.
|
|
114
|
+
if feed := self.feed_input.value.strip():
|
|
109
115
|
self.dismiss(
|
|
110
116
|
NewSubscriptionData(
|
|
111
117
|
feed=feed,
|
|
112
|
-
folder=self.
|
|
118
|
+
folder=self.folder_input.value.strip(),
|
|
113
119
|
)
|
|
114
120
|
)
|
|
115
121
|
|
oldnews/sync.py
CHANGED
|
@@ -21,6 +21,7 @@ from oldas import (
|
|
|
21
21
|
##############################################################################
|
|
22
22
|
from .data import (
|
|
23
23
|
LocalUnread,
|
|
24
|
+
Log,
|
|
24
25
|
get_local_subscriptions,
|
|
25
26
|
get_local_unread,
|
|
26
27
|
get_unread_article_ids,
|
|
@@ -28,6 +29,7 @@ from .data import (
|
|
|
28
29
|
load_configuration,
|
|
29
30
|
locally_mark_article_ids_read,
|
|
30
31
|
remember_we_last_grabbed_at,
|
|
32
|
+
remove_subscription_articles,
|
|
31
33
|
save_local_articles,
|
|
32
34
|
save_local_folders,
|
|
33
35
|
save_local_subscriptions,
|
|
@@ -60,12 +62,22 @@ class ToRSync:
|
|
|
60
62
|
on_sync_finished: Callback = None
|
|
61
63
|
"""Function to call when the sync has finished."""
|
|
62
64
|
|
|
63
|
-
def
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
"""Initialise the sync object."""
|
|
67
|
+
self._last_sync = last_grabbed_data_at()
|
|
68
|
+
"""The time at which we last did a sync."""
|
|
69
|
+
self._first_sync = self._last_sync is None
|
|
70
|
+
"""Is this our first ever sync?"""
|
|
71
|
+
|
|
72
|
+
def _step(self, step: str, *, log: bool = True) -> None:
|
|
64
73
|
"""Mark a new step.
|
|
65
74
|
|
|
66
75
|
Args:
|
|
67
76
|
step: The step that is happening.
|
|
77
|
+
log: Should we log the step?
|
|
68
78
|
"""
|
|
79
|
+
if log:
|
|
80
|
+
Log().info(step)
|
|
69
81
|
if self.on_new_step:
|
|
70
82
|
self.on_new_step(step)
|
|
71
83
|
|
|
@@ -75,6 +87,7 @@ class ToRSync:
|
|
|
75
87
|
Args:
|
|
76
88
|
result: The result that should be shown.
|
|
77
89
|
"""
|
|
90
|
+
Log().info(result)
|
|
78
91
|
if self.on_new_result:
|
|
79
92
|
self.on_new_result(result)
|
|
80
93
|
|
|
@@ -103,34 +116,21 @@ class ToRSync:
|
|
|
103
116
|
save_local_articles(Articles([article]))
|
|
104
117
|
loaded += 1
|
|
105
118
|
if (loaded % 10) == 0:
|
|
106
|
-
self._step(f"{description}: {loaded}")
|
|
119
|
+
self._step(f"{description}: {loaded}", log=False)
|
|
107
120
|
return loaded
|
|
108
121
|
|
|
109
|
-
async def
|
|
110
|
-
"""Download the latest articles available."""
|
|
111
|
-
new_grab = datetime.now(timezone.utc)
|
|
112
|
-
last_grabbed = last_grabbed_data_at() or (
|
|
113
|
-
new_grab - timedelta(days=load_configuration().local_history)
|
|
114
|
-
)
|
|
115
|
-
if loaded := await self._download(
|
|
116
|
-
Articles.stream_new_since(self.session, last_grabbed, n=10),
|
|
117
|
-
"Downloading articles from TheOldReader",
|
|
118
|
-
):
|
|
119
|
-
self._result(f"Articles downloaded: {loaded}")
|
|
120
|
-
else:
|
|
121
|
-
self._result("No new articles found on TheOldReader")
|
|
122
|
-
remember_we_last_grabbed_at(new_grab)
|
|
123
|
-
|
|
124
|
-
async def _refresh_read_status(self) -> None:
|
|
122
|
+
async def _get_updated_read_status(self) -> None:
|
|
125
123
|
"""Refresh the read status from the server."""
|
|
126
|
-
self.
|
|
124
|
+
if self._first_sync:
|
|
125
|
+
return
|
|
126
|
+
self._step("Syncing read/unread status with TheOldReader")
|
|
127
127
|
remote_unread_articles = set(
|
|
128
128
|
article_id.full_id
|
|
129
129
|
for article_id in await ArticleIDs.load_unread(self.session)
|
|
130
130
|
)
|
|
131
|
-
self._step("Comparing against locally-read articles")
|
|
132
131
|
local_unread_articles = set(get_unread_article_ids())
|
|
133
132
|
if mark_as_read := local_unread_articles - remote_unread_articles:
|
|
133
|
+
Log().debug(f"Articles found as marked read elsewhere: {mark_as_read}")
|
|
134
134
|
locally_mark_article_ids_read(mark_as_read)
|
|
135
135
|
self._result(
|
|
136
136
|
f"Articles found read elsewhere on TheOldReader: {len(mark_as_read)}"
|
|
@@ -154,61 +154,134 @@ class ToRSync:
|
|
|
154
154
|
f"Downloaded article backlog for {subscription.title}: {loaded}"
|
|
155
155
|
)
|
|
156
156
|
|
|
157
|
-
async def
|
|
158
|
-
"""
|
|
157
|
+
async def _get_folders(self) -> Folders:
|
|
158
|
+
"""Get the list of folders from the server.
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
|
|
160
|
+
Returns:
|
|
161
|
+
The folders.
|
|
162
162
|
"""
|
|
163
|
-
|
|
164
|
-
# Get the folder list.
|
|
165
163
|
self._step("Getting folder list")
|
|
166
164
|
folders = save_local_folders(await Folders.load(self.session))
|
|
167
165
|
if self.on_new_folders:
|
|
168
166
|
self.on_new_folders(folders)
|
|
167
|
+
return folders
|
|
169
168
|
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
async def _get_subscriptions(self) -> tuple[Subscriptions, Subscriptions]:
|
|
170
|
+
"""Get the list of subscriptions from the server.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
A tuple of the original subscription list before the sync, and
|
|
174
|
+
after.
|
|
175
|
+
"""
|
|
172
176
|
self._step("Getting subscriptions list")
|
|
177
|
+
original_subscriptions = get_local_subscriptions()
|
|
173
178
|
subscriptions = save_local_subscriptions(await Subscriptions.load(self.session))
|
|
174
179
|
if self.on_new_subscriptions:
|
|
175
180
|
self.on_new_subscriptions(subscriptions)
|
|
181
|
+
return original_subscriptions, subscriptions
|
|
176
182
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
183
|
+
async def _get_new_articles(self) -> None:
|
|
184
|
+
"""Download any new articles."""
|
|
185
|
+
self._step(
|
|
186
|
+
"Getting available articles"
|
|
187
|
+
if self._first_sync
|
|
188
|
+
else f"Getting new articles since {self._last_sync}"
|
|
189
|
+
)
|
|
190
|
+
new_grab = datetime.now(timezone.utc)
|
|
191
|
+
last_grabbed = self._last_sync or (
|
|
192
|
+
new_grab - timedelta(days=load_configuration().local_history)
|
|
193
|
+
)
|
|
194
|
+
if loaded := await self._download(
|
|
195
|
+
Articles.stream_new_since(self.session, last_grabbed, n=10),
|
|
196
|
+
"Downloading articles from TheOldReader",
|
|
197
|
+
):
|
|
198
|
+
self._result(f"Articles downloaded: {loaded}")
|
|
180
199
|
else:
|
|
181
|
-
self.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
200
|
+
self._result("No new articles found on TheOldReader")
|
|
201
|
+
remember_we_last_grabbed_at(new_grab)
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _set_of_ids(subscriptions: Subscriptions) -> set[str]:
|
|
205
|
+
"""Get a set of the IDs of the given subscriptions.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
subscriptions: The subscriptions to get the IDs of.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
A [set][`set`] of subscription IDs.
|
|
212
|
+
"""
|
|
213
|
+
return {subscription.id for subscription in subscriptions}
|
|
214
|
+
|
|
215
|
+
async def _get_historical_articles(
|
|
216
|
+
self,
|
|
217
|
+
original_subscriptions: Subscriptions,
|
|
218
|
+
current_subscriptions: Subscriptions,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Download article histories for any new subscriptions.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
original_subscriptions: The known subscriptions before the sync.
|
|
224
|
+
current_subscriptions: The subscriptions we're now subscribed to.
|
|
225
|
+
|
|
226
|
+
It's possible we have subscriptions we didn't know about before, so
|
|
227
|
+
we want to go and backfill their content regardless of read or
|
|
228
|
+
unread status. So, if it looks like we've grabbed data before but
|
|
229
|
+
now we have subscriptions we didn't know about before... let's grab
|
|
230
|
+
their history regardless.
|
|
231
|
+
"""
|
|
232
|
+
Log().info("Checking for historical articles")
|
|
233
|
+
if not self._first_sync and (
|
|
234
|
+
new_subscriptions := self._set_of_ids(current_subscriptions)
|
|
235
|
+
- self._set_of_ids(original_subscriptions)
|
|
236
|
+
):
|
|
237
|
+
Log().info(f"New subscriptions found: {new_subscriptions}")
|
|
238
|
+
await self._download_backlog(
|
|
239
|
+
subscription
|
|
240
|
+
for subscription in current_subscriptions
|
|
241
|
+
if subscription.id in new_subscriptions
|
|
196
242
|
)
|
|
197
|
-
now_subscribed_to = set(subscription.id for subscription in subscriptions)
|
|
198
|
-
if new_subscriptions := now_subscribed_to - was_subscribed_to:
|
|
199
|
-
await self._download_backlog(
|
|
200
|
-
subscription
|
|
201
|
-
for subscription in subscriptions
|
|
202
|
-
if subscription.id in new_subscriptions
|
|
203
|
-
)
|
|
204
243
|
|
|
205
|
-
|
|
206
|
-
self
|
|
244
|
+
def _clean_orphaned_articles(
|
|
245
|
+
self,
|
|
246
|
+
original_subscriptions: Subscriptions,
|
|
247
|
+
current_subscriptions: Subscriptions,
|
|
248
|
+
) -> None:
|
|
249
|
+
"""Clean any articles left over from removed subscriptions.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
original_subscriptions: The known subscriptions before the sync.
|
|
253
|
+
current_subscriptions: The subscriptions we're now subscribed to.
|
|
254
|
+
"""
|
|
255
|
+
Log().info("Checking for orphaned articles")
|
|
256
|
+
if not self._first_sync and (
|
|
257
|
+
removed_subscriptions := self._set_of_ids(original_subscriptions)
|
|
258
|
+
- self._set_of_ids(current_subscriptions)
|
|
259
|
+
):
|
|
260
|
+
Log().info(f"Found remotely-removed subscriptions: {removed_subscriptions}")
|
|
261
|
+
for subscription in removed_subscriptions:
|
|
262
|
+
remove_subscription_articles(subscription)
|
|
263
|
+
|
|
264
|
+
def _get_unread_counts(
|
|
265
|
+
self, folders: Folders, subscriptions: Subscriptions
|
|
266
|
+
) -> None:
|
|
267
|
+
"""Get the updated unread counts."""
|
|
207
268
|
unread = get_local_unread(folders, subscriptions)
|
|
208
269
|
if self.on_new_unread:
|
|
209
270
|
self.on_new_unread(unread)
|
|
210
271
|
|
|
211
|
-
|
|
272
|
+
async def refresh(self) -> None:
|
|
273
|
+
"""Refresh the data from TheOldReader.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
session: The TheOldReader API session object.
|
|
277
|
+
"""
|
|
278
|
+
folders = await self._get_folders()
|
|
279
|
+
original_subscriptions, subscriptions = await self._get_subscriptions()
|
|
280
|
+
await self._get_new_articles()
|
|
281
|
+
await self._get_updated_read_status()
|
|
282
|
+
await self._get_historical_articles(original_subscriptions, subscriptions)
|
|
283
|
+
self._clean_orphaned_articles(original_subscriptions, subscriptions)
|
|
284
|
+
self._get_unread_counts(folders, subscriptions)
|
|
212
285
|
if self.on_sync_finished:
|
|
213
286
|
self.on_sync_finished()
|
|
214
287
|
|
|
@@ -16,6 +16,7 @@ from oldas import Article
|
|
|
16
16
|
# Textual imports.
|
|
17
17
|
from textual.app import ComposeResult
|
|
18
18
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
19
|
+
from textual.getters import query_one
|
|
19
20
|
from textual.reactive import var
|
|
20
21
|
from textual.widgets import Label, Markdown, Rule
|
|
21
22
|
|
|
@@ -78,6 +79,17 @@ class ArticleContent(Vertical):
|
|
|
78
79
|
article: var[Article | None] = var(None)
|
|
79
80
|
"""The article being viewed."""
|
|
80
81
|
|
|
82
|
+
title = query_one("#title", Label)
|
|
83
|
+
"""The title label."""
|
|
84
|
+
published = query_one("#published", Label)
|
|
85
|
+
"""The published date label."""
|
|
86
|
+
link = query_one("#link", Label)
|
|
87
|
+
"""The link label."""
|
|
88
|
+
markdown = query_one(Markdown)
|
|
89
|
+
"""The markdown display for the article."""
|
|
90
|
+
content = query_one(VerticalScroll)
|
|
91
|
+
"""The article content."""
|
|
92
|
+
|
|
81
93
|
def compose(self) -> ComposeResult:
|
|
82
94
|
"""Compose the content of the widget."""
|
|
83
95
|
yield Rule()
|
|
@@ -93,20 +105,19 @@ class ArticleContent(Vertical):
|
|
|
93
105
|
async def _watch_article(self) -> None:
|
|
94
106
|
"""React to the article being updated."""
|
|
95
107
|
if self.article is not None:
|
|
96
|
-
self.
|
|
97
|
-
self.
|
|
98
|
-
link = self.query_one("#link", Label)
|
|
108
|
+
self.title.update(self.article.title)
|
|
109
|
+
self.published.update(str(self.article.published))
|
|
99
110
|
if self.article.html_url is None:
|
|
100
|
-
link.visible = False
|
|
111
|
+
self.link.visible = False
|
|
101
112
|
else:
|
|
102
|
-
link.visible = True
|
|
103
|
-
link.update(self.article.html_url)
|
|
104
|
-
await self.
|
|
105
|
-
self.
|
|
113
|
+
self.link.visible = True
|
|
114
|
+
self.link.update(self.article.html_url)
|
|
115
|
+
await self.markdown.update(convert(self.article.summary.content))
|
|
116
|
+
self.content.scroll_home(animate=False)
|
|
106
117
|
self.set_class(self.article is not None, "--has-article")
|
|
107
118
|
|
|
108
119
|
def focus(self, scroll_visible: bool = True) -> Self:
|
|
109
|
-
self.
|
|
120
|
+
self.content.focus(scroll_visible)
|
|
110
121
|
return self
|
|
111
122
|
|
|
112
123
|
|
oldnews/widgets/navigation.py
CHANGED
|
@@ -123,7 +123,13 @@ class Navigation(EnhancedOptionList):
|
|
|
123
123
|
"""
|
|
124
124
|
|
|
125
125
|
BINDINGS = [
|
|
126
|
-
HelpfulBinding("ctrl+enter", "toggle_folder", tooltip="Expand/collapse folder")
|
|
126
|
+
HelpfulBinding("ctrl+enter", "toggle_folder", tooltip="Expand/collapse folder"),
|
|
127
|
+
HelpfulBinding(
|
|
128
|
+
"ctrl+right_square_bracket", "expand_all", tooltip="Expand all folders"
|
|
129
|
+
),
|
|
130
|
+
HelpfulBinding(
|
|
131
|
+
"ctrl+left_square_bracket", "collapse_all", tooltip="Collapse all folders"
|
|
132
|
+
),
|
|
127
133
|
]
|
|
128
134
|
|
|
129
135
|
folders: var[Folders] = var(Folders)
|
|
@@ -213,6 +219,18 @@ class Navigation(EnhancedOptionList):
|
|
|
213
219
|
self._save_state()
|
|
214
220
|
self._refresh_navigation()
|
|
215
221
|
|
|
222
|
+
def _action_expand_all(self) -> None:
|
|
223
|
+
"""Action that expands all folders."""
|
|
224
|
+
self._expanded = {folder.id for folder in self.folders}
|
|
225
|
+
self._save_state()
|
|
226
|
+
self._refresh_navigation()
|
|
227
|
+
|
|
228
|
+
def _action_collapse_all(self) -> None:
|
|
229
|
+
"""Action that collapses all folders."""
|
|
230
|
+
self._expanded = set()
|
|
231
|
+
self._save_state()
|
|
232
|
+
self._refresh_navigation()
|
|
233
|
+
|
|
216
234
|
@work(thread=True)
|
|
217
235
|
def _save_state(self) -> None:
|
|
218
236
|
"""Save the folder expanded/collapsed state."""
|
|
@@ -1,37 +1,39 @@
|
|
|
1
1
|
oldnews/__init__.py,sha256=p5a3l8kly6VYQ4IjiC6s03Nj-FaLDTOzqU1ZC6WZLl0,540
|
|
2
|
-
oldnews/__main__.py,sha256=
|
|
2
|
+
oldnews/__main__.py,sha256=RJCfjo9MVOYC1K6G7YG7jH8NPMqRzW79SZTcbsoViG0,4703
|
|
3
3
|
oldnews/commands/__init__.py,sha256=R8w9ccEYqZ4AKdtg2vvT_ejgdC3rsQ7yiTVnYVWDLOQ,1042
|
|
4
4
|
oldnews/commands/main.py,sha256=lPfOIYwf8L7eYG9GUy2MDvoj4fl3ZikjY8Ld-dmv7i0,4089
|
|
5
|
-
oldnews/data/__init__.py,sha256=
|
|
5
|
+
oldnews/data/__init__.py,sha256=pIkCj0LmMG2PckLWZ1ohDgIqgxqyx4o9tTo1oUnfauw,2098
|
|
6
6
|
oldnews/data/auth.py,sha256=5CWu4kjEkyJ8TDqqIYkUNFAzebygpksKIPdFhT62uOM,1217
|
|
7
7
|
oldnews/data/config.py,sha256=nhgPwKDSHQ5Fvi9u0o6lpCerglPuI7ImMzYRasbs1v4,3352
|
|
8
8
|
oldnews/data/db.py,sha256=IN3TOORqLbd-c8jXnHIK0Y_WVWav7GdgxSUTeKYSp0Y,2793
|
|
9
|
+
oldnews/data/dump.py,sha256=sgWz1HHJlxG_B8uDtwtt_TS1ulnP4yji3hK7iinMc7Q,2206
|
|
9
10
|
oldnews/data/last_grab.py,sha256=9GYl_U0KdLrHiAHCb-c72uNMseKW7aQwJxLyGU3d574,1594
|
|
10
|
-
oldnews/data/local_articles.py,sha256=
|
|
11
|
+
oldnews/data/local_articles.py,sha256=6MnE0rDCib8XFZaiAmrdefb6qZySLlVJYaj89ulyu3Y,15099
|
|
11
12
|
oldnews/data/local_folders.py,sha256=ku2-6XQb2SMZdqVJ7DOqDiAT8PtmqzU8W51TQSsYcvw,1560
|
|
12
13
|
oldnews/data/local_subscriptions.py,sha256=2wZudEvykr1U1NYhdUUnR9u4r-eR8MGVzoi06qi9zI0,3766
|
|
13
14
|
oldnews/data/local_unread.py,sha256=iXhiFbHG7_7EZHtaf8hxc07yOZKQu_JrjodNnMTIllg,1442
|
|
14
15
|
oldnews/data/locations.py,sha256=rJqXy_15ok5G86XbrXmixyYTBLFO0V0BWD8rLKotuBg,1723
|
|
16
|
+
oldnews/data/log.py,sha256=nd6w7NAiUzZjeVM4HZd-oWFFVyQvWblXjdRYlSjua5k,1211
|
|
15
17
|
oldnews/data/navigation_state.py,sha256=bBu7swWDVMD52I3pBZbWLAxJNpm96aqDaMNVH16tKto,1319
|
|
16
18
|
oldnews/data/reset.py,sha256=K-AqeEsj4CIMTK1yX-BTdTX0Eyg5bFG8wE2-qoMq5zo,765
|
|
17
19
|
oldnews/data/tools.py,sha256=FzjcYB6xKFpW5_Rd-8VO3c-7VIeRZV8gj9qMRAEdlys,1390
|
|
18
20
|
oldnews/oldnews.py,sha256=xdmm0UaLmPReZhDsjfMNeL_mHh2-rIs74gh3H1vqYZE,3871
|
|
19
21
|
oldnews/providers/__init__.py,sha256=R_wS987EuI3KDiN9Xzf0P5wiRSYLOk_-3twvr2ML7WU,354
|
|
20
|
-
oldnews/providers/main.py,sha256=
|
|
22
|
+
oldnews/providers/main.py,sha256=kHyXfumWV6pZhLUfAe6LCsEYQmITYmV0RxXIn0qmlNs,2020
|
|
21
23
|
oldnews/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
24
|
oldnews/screens/__init__.py,sha256=-Iif16T316dnZkgJjX1YE7e4v7FxI0k_V08qd5za0t8,330
|
|
23
25
|
oldnews/screens/folder_input.py,sha256=i6KeRVD1Ucfg2T6VJmzdKKnUHf6qo_1ka0ZLlNMuabA,1869
|
|
24
26
|
oldnews/screens/information_display.py,sha256=-qG1BJxnhF9HtaP8d_HIuRzwMlAwl5raCLIAFpIDODg,2543
|
|
25
|
-
oldnews/screens/login.py,sha256=
|
|
26
|
-
oldnews/screens/main.py,sha256=
|
|
27
|
-
oldnews/screens/new_subscription.py,sha256=
|
|
28
|
-
oldnews/sync.py,sha256=
|
|
27
|
+
oldnews/screens/login.py,sha256=a2hUERxpKYg7GQIYlpyDgHTAUEKmsANjwHZx1r-gXSw,3377
|
|
28
|
+
oldnews/screens/main.py,sha256=AuCL54UC-DHl-fyRTr1_lSS2hCBXQKizQirpWpLBazs,28618
|
|
29
|
+
oldnews/screens/new_subscription.py,sha256=bk01RXWu3kvJbtm88o1mrYpll73Op3PWWl7ggIH29kk,4011
|
|
30
|
+
oldnews/sync.py,sha256=OZv5JvImvzPAjV6cuCbiSoIeVy0TnfWAq-rL-Xs_ffk,10877
|
|
29
31
|
oldnews/widgets/__init__.py,sha256=5VSjKswHxv2W8g0O-LWlUFS-gy7iQrIM8PGvSBhDNLU,438
|
|
30
32
|
oldnews/widgets/_after_highlight.py,sha256=4GgMx0SYU05K3K7xFk22N6ZSn5wewnAjA062mQT6jBg,1904
|
|
31
|
-
oldnews/widgets/article_content.py,sha256=
|
|
33
|
+
oldnews/widgets/article_content.py,sha256=pv8zobd3eTsALHYmP854dd_enqy32QXGoWXEILYnWfA,3553
|
|
32
34
|
oldnews/widgets/article_list.py,sha256=dSmSGlk1X94ryy0bJ6ejc2WXO0XMvTWyTxEtR1cL_5k,7428
|
|
33
|
-
oldnews/widgets/navigation.py,sha256=
|
|
34
|
-
oldnews-0.
|
|
35
|
-
oldnews-0.
|
|
36
|
-
oldnews-0.
|
|
37
|
-
oldnews-0.
|
|
35
|
+
oldnews/widgets/navigation.py,sha256=QE_jyavY-A4mugEmFVOo1Ed7g9GfK4wEkrnjO5YQLtA,11019
|
|
36
|
+
oldnews-0.6.0.dist-info/WHEEL,sha256=XV0cjMrO7zXhVAIyyc8aFf1VjZ33Fen4IiJk5zFlC3g,80
|
|
37
|
+
oldnews-0.6.0.dist-info/entry_points.txt,sha256=FxY6Y4IsHZubhtdd0QJG3p2kCTcXe0e-Ib_AW0qkotE,51
|
|
38
|
+
oldnews-0.6.0.dist-info/METADATA,sha256=vVA5ZPZ3r4E1rGLVg9ibHGeZwJ62j-zAakuoVRZ6_m4,2917
|
|
39
|
+
oldnews-0.6.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|