oldnews 0.2.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.2.0 → oldnews-0.3.0}/PKG-INFO +2 -2
- {oldnews-0.2.0 → oldnews-0.3.0}/pyproject.toml +2 -2
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/__main__.py +67 -24
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/data/__init__.py +2 -0
- oldnews-0.3.0/src/oldnews/data/reset.py +26 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/screens/main.py +28 -97
- oldnews-0.3.0/src/oldnews/sync/__init__.py +11 -0
- oldnews-0.3.0/src/oldnews/sync/sync.py +158 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/widgets/article_content.py +17 -2
- {oldnews-0.2.0 → oldnews-0.3.0}/README.md +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/__init__.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/commands/__init__.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/commands/main.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/data/auth.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/data/config.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/data/db.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/data/last_grab.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/data/local_articles.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/data/local_folders.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/data/local_subscriptions.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/data/local_unread.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/data/locations.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/data/navigation_state.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/oldnews.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/providers/__init__.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/providers/main.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/py.typed +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/screens/__init__.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/screens/login.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/widgets/__init__.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/widgets/_after_highlight.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/widgets/article_list.py +0 -0
- {oldnews-0.2.0 → oldnews-0.3.0}/src/oldnews/widgets/navigation.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
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 = [
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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,
|
|
@@ -57,19 +56,15 @@ from ..data import (
|
|
|
57
56
|
get_local_folders,
|
|
58
57
|
get_local_subscriptions,
|
|
59
58
|
get_local_unread,
|
|
60
|
-
get_unread_article_ids,
|
|
61
59
|
last_grabbed_data_at,
|
|
62
60
|
load_configuration,
|
|
63
61
|
locally_mark_article_ids_read,
|
|
64
62
|
locally_mark_read,
|
|
65
|
-
remember_we_last_grabbed_at,
|
|
66
|
-
save_local_articles,
|
|
67
|
-
save_local_folders,
|
|
68
|
-
save_local_subscriptions,
|
|
69
63
|
total_unread,
|
|
70
64
|
update_configuration,
|
|
71
65
|
)
|
|
72
66
|
from ..providers import MainCommands
|
|
67
|
+
from ..sync import ToRSync
|
|
73
68
|
from ..widgets import ArticleContent, ArticleList, Navigation
|
|
74
69
|
|
|
75
70
|
|
|
@@ -198,6 +193,9 @@ class Main(EnhancedScreen[None]):
|
|
|
198
193
|
counts: LocalUnread
|
|
199
194
|
"""The new unread counts."""
|
|
200
195
|
|
|
196
|
+
class SyncFinished(Message):
|
|
197
|
+
"""Message sent when a sync from TheOldReader is finished."""
|
|
198
|
+
|
|
201
199
|
def __init__(self, session: Session) -> None:
|
|
202
200
|
"""Initialise the main screen."""
|
|
203
201
|
super().__init__()
|
|
@@ -298,6 +296,11 @@ class Main(EnhancedScreen[None]):
|
|
|
298
296
|
self.unread = message.counts
|
|
299
297
|
self.post_message(self.SubTitle(""))
|
|
300
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
|
+
|
|
301
304
|
@work(thread=True, exclusive=True)
|
|
302
305
|
def _load_locally(self) -> None:
|
|
303
306
|
"""Load up any locally-held data."""
|
|
@@ -311,6 +314,7 @@ class Main(EnhancedScreen[None]):
|
|
|
311
314
|
self.notify(f"Old read articles cleaned from local storage: {cleaned}")
|
|
312
315
|
if unread := get_local_unread(folders, subscriptions):
|
|
313
316
|
self.post_message(self.NewUnread(unread))
|
|
317
|
+
self._refresh_article_list()
|
|
314
318
|
# If we've never grabbed data from ToR before, or if it's been long enough...
|
|
315
319
|
if (last_grabbed := last_grabbed_data_at()) is None or (
|
|
316
320
|
(datetime.now() - last_grabbed).seconds
|
|
@@ -319,95 +323,27 @@ class Main(EnhancedScreen[None]):
|
|
|
319
323
|
# ...kick off a refresh from TheOldReader.
|
|
320
324
|
self.post_message(RefreshFromTheOldReader())
|
|
321
325
|
|
|
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
|
-
)
|
|
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(""))
|
|
370
331
|
|
|
371
332
|
@on(RefreshFromTheOldReader)
|
|
372
333
|
@work(exclusive=True)
|
|
373
334
|
async def action_refresh_from_the_old_reader_command(self) -> None:
|
|
374
335
|
"""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(""))
|
|
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()
|
|
411
347
|
|
|
412
348
|
@on(Navigation.CategorySelected)
|
|
413
349
|
def _handle_navigaion_selection(self, message: Navigation.CategorySelected) -> None:
|
|
@@ -418,14 +354,9 @@ class Main(EnhancedScreen[None]):
|
|
|
418
354
|
"""
|
|
419
355
|
self.current_category = message.category
|
|
420
356
|
self.article = None
|
|
421
|
-
self.
|
|
357
|
+
self._refresh_article_list()
|
|
422
358
|
self.query_one(ArticleList).focus()
|
|
423
359
|
|
|
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
360
|
def _watch_show_all(self) -> None:
|
|
430
361
|
"""Handle changes to the show all flag."""
|
|
431
362
|
self._refresh_article_list()
|
|
@@ -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
|
|
@@ -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:
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|