tbr-deal-finder 0.2.0__py3-none-any.whl → 0.3.1__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.
- tbr_deal_finder/__init__.py +1 -5
- tbr_deal_finder/__main__.py +7 -0
- tbr_deal_finder/book.py +28 -8
- tbr_deal_finder/cli.py +15 -28
- tbr_deal_finder/config.py +3 -3
- tbr_deal_finder/desktop_updater.py +147 -0
- tbr_deal_finder/gui/__init__.py +0 -0
- tbr_deal_finder/gui/main.py +725 -0
- tbr_deal_finder/gui/pages/__init__.py +1 -0
- tbr_deal_finder/gui/pages/all_books.py +93 -0
- tbr_deal_finder/gui/pages/all_deals.py +63 -0
- tbr_deal_finder/gui/pages/base_book_page.py +291 -0
- tbr_deal_finder/gui/pages/book_details.py +604 -0
- tbr_deal_finder/gui/pages/latest_deals.py +370 -0
- tbr_deal_finder/gui/pages/settings.py +389 -0
- tbr_deal_finder/migrations.py +26 -0
- tbr_deal_finder/queries/latest_unknown_book_sync.sql +5 -0
- tbr_deal_finder/retailer/amazon.py +60 -9
- tbr_deal_finder/retailer/amazon_custom_auth.py +79 -0
- tbr_deal_finder/retailer/audible.py +2 -1
- tbr_deal_finder/retailer/chirp.py +55 -11
- tbr_deal_finder/retailer/kindle.py +68 -44
- tbr_deal_finder/retailer/librofm.py +58 -21
- tbr_deal_finder/retailer/models.py +31 -1
- tbr_deal_finder/retailer_deal.py +62 -21
- tbr_deal_finder/tracked_books.py +76 -8
- tbr_deal_finder/utils.py +64 -2
- tbr_deal_finder/version_check.py +40 -0
- {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/METADATA +19 -88
- tbr_deal_finder-0.3.1.dist-info/RECORD +38 -0
- {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/entry_points.txt +1 -0
- tbr_deal_finder-0.2.0.dist-info/RECORD +0 -24
- {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/WHEEL +0 -0
- {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1 @@
|
|
1
|
+
# GUI pages package
|
@@ -0,0 +1,93 @@
|
|
1
|
+
import logging
|
2
|
+
|
3
|
+
import flet as ft
|
4
|
+
|
5
|
+
from tbr_deal_finder.book import Book, BookFormat
|
6
|
+
from tbr_deal_finder.tracked_books import get_tbr_books
|
7
|
+
from tbr_deal_finder.gui.pages.base_book_page import BaseBookPage
|
8
|
+
|
9
|
+
logger = logging.getLogger(__name__)
|
10
|
+
|
11
|
+
|
12
|
+
class AllBooksPage(BaseBookPage):
|
13
|
+
def __init__(self, app):
|
14
|
+
super().__init__(app, items_per_page=7)
|
15
|
+
|
16
|
+
def get_page_title(self) -> str:
|
17
|
+
return "My Books"
|
18
|
+
|
19
|
+
def get_empty_state_message(self) -> tuple[str, str]:
|
20
|
+
return (
|
21
|
+
"No books found",
|
22
|
+
"Try adjusting your search or check your library export files in settings"
|
23
|
+
)
|
24
|
+
|
25
|
+
def get_format_filter_options(self):
|
26
|
+
"""Include 'Either Format' option for TBR books"""
|
27
|
+
return ["All", "E-Book", "Audiobook", "Either Format"]
|
28
|
+
|
29
|
+
def load_items(self):
|
30
|
+
"""Load TBR books from database"""
|
31
|
+
if not self.app.config:
|
32
|
+
self.items = []
|
33
|
+
self.filtered_items = []
|
34
|
+
return
|
35
|
+
|
36
|
+
# Set loading state and use Flet's proper async task runner
|
37
|
+
self.set_loading(True)
|
38
|
+
self.app.page.run_task(self._async_load_items)
|
39
|
+
|
40
|
+
async def _async_load_items(self):
|
41
|
+
"""Load TBR books asynchronously using Flet's async support"""
|
42
|
+
try:
|
43
|
+
# Run the async operation directly
|
44
|
+
await self.app.auth_all_configured_retailers()
|
45
|
+
self.items = await get_tbr_books(self.app.config)
|
46
|
+
self.apply_filters()
|
47
|
+
except Exception as e:
|
48
|
+
logger.error(f"Error loading TBR books: {e}")
|
49
|
+
self.items = []
|
50
|
+
self.filtered_items = []
|
51
|
+
finally:
|
52
|
+
self.set_loading(False)
|
53
|
+
# Update the page to reflect the loaded data
|
54
|
+
self.app.page.update()
|
55
|
+
|
56
|
+
|
57
|
+
def filter_by_format(self, items, format_filter: str):
|
58
|
+
"""Custom format filter that includes 'Either Format' option"""
|
59
|
+
if format_filter == "E-Book":
|
60
|
+
return [item for item in items if item.format == BookFormat.EBOOK]
|
61
|
+
elif format_filter == "Audiobook":
|
62
|
+
return [item for item in items if item.format == BookFormat.AUDIOBOOK]
|
63
|
+
elif format_filter == "Either Format":
|
64
|
+
return [item for item in items if item.format == BookFormat.NA]
|
65
|
+
else:
|
66
|
+
return items
|
67
|
+
|
68
|
+
def create_item_tile(self, book: Book):
|
69
|
+
"""Create a tile for a single book"""
|
70
|
+
# Truncate title if too long
|
71
|
+
title = book.title
|
72
|
+
if len(title) > 60:
|
73
|
+
title = f"{title[:60]}..."
|
74
|
+
|
75
|
+
return ft.Card(
|
76
|
+
content=ft.Container(
|
77
|
+
content=ft.ListTile(
|
78
|
+
title=ft.Text(title, weight=ft.FontWeight.BOLD),
|
79
|
+
subtitle=ft.Column([
|
80
|
+
ft.Text(f"by {book.authors}", color=ft.Colors.GREY_600),
|
81
|
+
], spacing=2),
|
82
|
+
on_click=lambda e, b=book: self.app.show_book_details(b, b.format)
|
83
|
+
),
|
84
|
+
padding=10,
|
85
|
+
on_click=lambda e, b=book: self.app.show_book_details(b, b.format)
|
86
|
+
)
|
87
|
+
)
|
88
|
+
|
89
|
+
def check_book_has_deals(self, book: Book) -> bool:
|
90
|
+
"""Check if a book has active deals (simplified check)"""
|
91
|
+
# This is a simplified check - in a real implementation you might
|
92
|
+
# want to query the deals database to see if this book has active deals
|
93
|
+
return False # Placeholder
|
@@ -0,0 +1,63 @@
|
|
1
|
+
import logging
|
2
|
+
|
3
|
+
import flet as ft
|
4
|
+
|
5
|
+
from tbr_deal_finder.book import get_active_deals, Book, is_qualifying_deal
|
6
|
+
from tbr_deal_finder.gui.pages.base_book_page import BaseBookPage
|
7
|
+
|
8
|
+
logger = logging.getLogger(__name__)
|
9
|
+
|
10
|
+
class AllDealsPage(BaseBookPage):
|
11
|
+
def __init__(self, app):
|
12
|
+
super().__init__(app, items_per_page=6)
|
13
|
+
|
14
|
+
def get_page_title(self) -> str:
|
15
|
+
return "All Active Deals"
|
16
|
+
|
17
|
+
def get_empty_state_message(self) -> tuple[str, str]:
|
18
|
+
return ("No deals found", "Try adjusting your search or filters")
|
19
|
+
|
20
|
+
def load_items(self):
|
21
|
+
"""Load active deals from database"""
|
22
|
+
try:
|
23
|
+
self.items = [
|
24
|
+
book for book in get_active_deals()
|
25
|
+
if is_qualifying_deal(self.app.config, book)
|
26
|
+
]
|
27
|
+
self.apply_filters()
|
28
|
+
except Exception as e:
|
29
|
+
self.items = []
|
30
|
+
self.filtered_items = []
|
31
|
+
logger.error(f"Error loading deals: {e}")
|
32
|
+
|
33
|
+
def create_item_tile(self, deal: Book):
|
34
|
+
"""Create a tile for a single deal"""
|
35
|
+
# Truncate title if too long
|
36
|
+
title = deal.title
|
37
|
+
if len(title) > 60:
|
38
|
+
title = f"{title[:60]}..."
|
39
|
+
|
40
|
+
# Format price and discount
|
41
|
+
price_text = f"{deal.current_price_string()} ({deal.discount()}% off)"
|
42
|
+
original_price = deal.list_price_string()
|
43
|
+
|
44
|
+
return ft.Card(
|
45
|
+
content=ft.Container(
|
46
|
+
content=ft.ListTile(
|
47
|
+
title=ft.Text(title, weight=ft.FontWeight.BOLD),
|
48
|
+
subtitle=ft.Column([
|
49
|
+
ft.Text(f"by {deal.authors}", color=ft.Colors.GREY_600),
|
50
|
+
ft.Row([
|
51
|
+
ft.Text(price_text, color=ft.Colors.GREEN, weight=ft.FontWeight.BOLD),
|
52
|
+
ft.Text(f"was {original_price}", color=ft.Colors.GREY_500, size=12)
|
53
|
+
])
|
54
|
+
], spacing=2),
|
55
|
+
trailing=ft.Column([
|
56
|
+
ft.Text(deal.retailer, weight=ft.FontWeight.BOLD, size=12)
|
57
|
+
], alignment=ft.MainAxisAlignment.CENTER),
|
58
|
+
on_click=lambda e, book=deal: self.app.show_book_details(book, book.format)
|
59
|
+
),
|
60
|
+
padding=10,
|
61
|
+
on_click=lambda e, book=deal: self.app.show_book_details(book, book.format)
|
62
|
+
)
|
63
|
+
)
|
@@ -0,0 +1,291 @@
|
|
1
|
+
import flet as ft
|
2
|
+
from abc import ABC, abstractmethod
|
3
|
+
from typing import List, Any
|
4
|
+
|
5
|
+
from tbr_deal_finder.book import Book, BookFormat
|
6
|
+
|
7
|
+
|
8
|
+
class BaseBookPage(ABC):
|
9
|
+
"""Base class for pages that display lists of books with pagination, search, and filtering."""
|
10
|
+
|
11
|
+
def __init__(self, app, items_per_page: int):
|
12
|
+
self.app = app
|
13
|
+
self.items = []
|
14
|
+
self.filtered_items = []
|
15
|
+
self.current_page = 0
|
16
|
+
self.items_per_page = items_per_page
|
17
|
+
self.search_query = ""
|
18
|
+
self.format_filter = "All"
|
19
|
+
self.is_loading = False
|
20
|
+
|
21
|
+
@abstractmethod
|
22
|
+
def get_page_title(self) -> str:
|
23
|
+
"""Return the page title."""
|
24
|
+
pass
|
25
|
+
|
26
|
+
@abstractmethod
|
27
|
+
def load_items(self):
|
28
|
+
"""Load items from data source. Should set self.items and call apply_filters()."""
|
29
|
+
pass
|
30
|
+
|
31
|
+
@abstractmethod
|
32
|
+
def create_item_tile(self, item: Any) -> ft.Control:
|
33
|
+
"""Create a tile for a single item."""
|
34
|
+
pass
|
35
|
+
|
36
|
+
@abstractmethod
|
37
|
+
def get_empty_state_message(self) -> tuple[str, str]:
|
38
|
+
"""Return (main_message, sub_message) for empty state."""
|
39
|
+
pass
|
40
|
+
|
41
|
+
def get_format_filter_options(self) -> List[str]:
|
42
|
+
"""Return available format filter options. Override if needed."""
|
43
|
+
return ["All", "E-Book", "Audiobook"]
|
44
|
+
|
45
|
+
def should_include_refresh_button(self) -> bool:
|
46
|
+
"""Whether to include a refresh button. Override if needed."""
|
47
|
+
return True
|
48
|
+
|
49
|
+
def build(self):
|
50
|
+
"""Build the page content"""
|
51
|
+
# Search and filter controls
|
52
|
+
search_controls = self.build_search_controls()
|
53
|
+
|
54
|
+
# Loading indicator
|
55
|
+
self.loading_container = ft.Container(
|
56
|
+
content=ft.Column([
|
57
|
+
ft.ProgressRing(),
|
58
|
+
ft.Text("Loading...", text_align=ft.TextAlign.CENTER),
|
59
|
+
ft.Text(
|
60
|
+
"Syncing and retrieving your TBR.\nThis may take a few minutes if you've recently made changes to your wishlist, exports, or this is your first time.",
|
61
|
+
text_align=ft.TextAlign.CENTER,
|
62
|
+
size=11
|
63
|
+
)
|
64
|
+
], spacing=10, horizontal_alignment=ft.CrossAxisAlignment.CENTER),
|
65
|
+
visible=self.is_loading,
|
66
|
+
alignment=ft.alignment.center,
|
67
|
+
height=200
|
68
|
+
)
|
69
|
+
|
70
|
+
if not self.items and not self.is_loading:
|
71
|
+
self.load_items()
|
72
|
+
|
73
|
+
# Items list container that we can update without rebuilding search controls
|
74
|
+
self.items_container = ft.Container()
|
75
|
+
self.pagination_container = ft.Container()
|
76
|
+
|
77
|
+
# Initial build of items and pagination
|
78
|
+
self.update_items_display()
|
79
|
+
|
80
|
+
return ft.Column([
|
81
|
+
ft.Text(self.get_page_title(), size=24, weight=ft.FontWeight.BOLD),
|
82
|
+
search_controls,
|
83
|
+
self.loading_container,
|
84
|
+
self.items_container,
|
85
|
+
self.pagination_container
|
86
|
+
], spacing=20, scroll=ft.ScrollMode.AUTO)
|
87
|
+
|
88
|
+
def build_search_controls(self):
|
89
|
+
"""Build search and filter controls"""
|
90
|
+
self.search_field = ft.TextField(
|
91
|
+
label="Search...",
|
92
|
+
prefix_icon=ft.Icons.SEARCH,
|
93
|
+
on_change=self.on_search_change,
|
94
|
+
value=self.search_query,
|
95
|
+
expand=True
|
96
|
+
)
|
97
|
+
|
98
|
+
self.format_dropdown = ft.Dropdown(
|
99
|
+
label="Format",
|
100
|
+
value=self.format_filter,
|
101
|
+
options=[ft.dropdown.Option(option) for option in self.get_format_filter_options()],
|
102
|
+
on_change=self.on_format_change,
|
103
|
+
width=150
|
104
|
+
)
|
105
|
+
|
106
|
+
controls = [self.search_field, self.format_dropdown]
|
107
|
+
|
108
|
+
if self.should_include_refresh_button():
|
109
|
+
refresh_button = ft.IconButton(
|
110
|
+
icon=ft.Icons.REFRESH,
|
111
|
+
tooltip="Refresh",
|
112
|
+
on_click=self.refresh_items
|
113
|
+
)
|
114
|
+
controls.append(refresh_button)
|
115
|
+
|
116
|
+
return ft.Row(controls, spacing=10)
|
117
|
+
|
118
|
+
def build_items_list(self):
|
119
|
+
"""Build the items list with current page items"""
|
120
|
+
if self.is_loading:
|
121
|
+
return ft.Container()
|
122
|
+
|
123
|
+
if not self.filtered_items:
|
124
|
+
main_msg, sub_msg = self.get_empty_state_message()
|
125
|
+
return ft.Container(
|
126
|
+
content=ft.Column([
|
127
|
+
ft.Icon(ft.Icons.SEARCH_OFF, size=64, color=ft.Colors.GREY_400),
|
128
|
+
ft.Text(main_msg, size=18, color=ft.Colors.GREY_600),
|
129
|
+
ft.Text(sub_msg, color=ft.Colors.GREY_500, text_align=ft.TextAlign.CENTER)
|
130
|
+
], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER),
|
131
|
+
alignment=ft.alignment.center,
|
132
|
+
height=300
|
133
|
+
)
|
134
|
+
|
135
|
+
start_idx = self.current_page * self.items_per_page
|
136
|
+
end_idx = min(start_idx + self.items_per_page, len(self.filtered_items))
|
137
|
+
page_items = self.filtered_items[start_idx:end_idx]
|
138
|
+
|
139
|
+
item_tiles = []
|
140
|
+
for item in page_items:
|
141
|
+
tile = self.create_item_tile(item)
|
142
|
+
item_tiles.append(tile)
|
143
|
+
|
144
|
+
return ft.Container(
|
145
|
+
content=ft.ListView(item_tiles, spacing=5),
|
146
|
+
height=700,
|
147
|
+
border=ft.border.all(1, ft.Colors.OUTLINE),
|
148
|
+
border_radius=8,
|
149
|
+
padding=10
|
150
|
+
)
|
151
|
+
|
152
|
+
def build_pagination(self):
|
153
|
+
"""Build pagination controls"""
|
154
|
+
if not self.filtered_items or self.is_loading:
|
155
|
+
return ft.Container()
|
156
|
+
|
157
|
+
total_pages = (len(self.filtered_items) + self.items_per_page - 1) // self.items_per_page
|
158
|
+
|
159
|
+
# Page info
|
160
|
+
start_item = self.current_page * self.items_per_page + 1
|
161
|
+
end_item = min((self.current_page + 1) * self.items_per_page, len(self.filtered_items))
|
162
|
+
|
163
|
+
page_info = ft.Text(
|
164
|
+
f"Showing {start_item}-{end_item} of {len(self.filtered_items)} items",
|
165
|
+
color=ft.Colors.GREY_600
|
166
|
+
)
|
167
|
+
|
168
|
+
# Navigation buttons
|
169
|
+
prev_button = ft.IconButton(
|
170
|
+
icon=ft.Icons.CHEVRON_LEFT,
|
171
|
+
on_click=self.prev_page,
|
172
|
+
disabled=self.current_page == 0
|
173
|
+
)
|
174
|
+
|
175
|
+
next_button = ft.IconButton(
|
176
|
+
icon=ft.Icons.CHEVRON_RIGHT,
|
177
|
+
on_click=self.next_page,
|
178
|
+
disabled=self.current_page >= total_pages - 1
|
179
|
+
)
|
180
|
+
|
181
|
+
page_number = ft.Text(
|
182
|
+
f"Page {self.current_page + 1} of {total_pages}",
|
183
|
+
weight=ft.FontWeight.BOLD
|
184
|
+
)
|
185
|
+
|
186
|
+
return ft.Row([
|
187
|
+
page_info,
|
188
|
+
ft.Row([prev_button, page_number, next_button], spacing=5)
|
189
|
+
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN)
|
190
|
+
|
191
|
+
def apply_filters(self):
|
192
|
+
"""Apply search and format filters"""
|
193
|
+
filtered = self.items
|
194
|
+
|
195
|
+
# Apply search filter
|
196
|
+
if self.search_query:
|
197
|
+
filtered = self.filter_by_search(filtered, self.search_query)
|
198
|
+
|
199
|
+
# Apply format filter
|
200
|
+
if self.format_filter != "All":
|
201
|
+
filtered = self.filter_by_format(filtered, self.format_filter)
|
202
|
+
|
203
|
+
# Apply custom sorting
|
204
|
+
self.filtered_items = self.sort_items(filtered)
|
205
|
+
self.current_page = 0 # Reset to first page when filters change
|
206
|
+
|
207
|
+
def filter_by_search(self, items: List[Book], query: str) -> List[Book]:
|
208
|
+
"""Filter items by search query. Override if needed."""
|
209
|
+
query = query.lower()
|
210
|
+
return [
|
211
|
+
item for item in items
|
212
|
+
if query in item.title.lower() or query in str(item.normalized_authors)
|
213
|
+
]
|
214
|
+
|
215
|
+
def filter_by_format(self, items: List[Book], format_filter: str) -> List[Book]:
|
216
|
+
"""Filter items by format. Override if needed."""
|
217
|
+
if format_filter == "E-Book":
|
218
|
+
format_value = BookFormat.EBOOK
|
219
|
+
elif format_filter == "Audiobook":
|
220
|
+
format_value = BookFormat.AUDIOBOOK
|
221
|
+
else:
|
222
|
+
return items
|
223
|
+
|
224
|
+
return [item for item in items if item.format == format_value]
|
225
|
+
|
226
|
+
def sort_items(self, items: List[Book]) -> List[Book]:
|
227
|
+
"""Sort items. Override to customize sorting."""
|
228
|
+
return sorted(items, key=lambda x: x.deal_id)
|
229
|
+
|
230
|
+
def on_search_change(self, e):
|
231
|
+
"""Handle search query changes"""
|
232
|
+
self.search_query = e.control.value
|
233
|
+
self.apply_filters()
|
234
|
+
self.update_items_display()
|
235
|
+
|
236
|
+
def on_format_change(self, e):
|
237
|
+
"""Handle format filter changes"""
|
238
|
+
self.format_filter = e.control.value
|
239
|
+
self.apply_filters()
|
240
|
+
self.update_items_display()
|
241
|
+
|
242
|
+
def refresh_items(self, e):
|
243
|
+
"""Refresh the items list"""
|
244
|
+
self.load_items()
|
245
|
+
self.update_items_display()
|
246
|
+
|
247
|
+
def prev_page(self, e):
|
248
|
+
"""Go to previous page"""
|
249
|
+
if self.current_page > 0:
|
250
|
+
self.current_page -= 1
|
251
|
+
self.update_items_display()
|
252
|
+
|
253
|
+
def next_page(self, e):
|
254
|
+
"""Go to next page"""
|
255
|
+
total_pages = (len(self.filtered_items) + self.items_per_page - 1) // self.items_per_page
|
256
|
+
if self.current_page < total_pages - 1:
|
257
|
+
self.current_page += 1
|
258
|
+
self.update_items_display()
|
259
|
+
|
260
|
+
def update_items_display(self):
|
261
|
+
"""Update only the items list and pagination, preserving search field state"""
|
262
|
+
if hasattr(self, 'items_container') and hasattr(self, 'pagination_container'):
|
263
|
+
self.items_container.content = self.build_items_list()
|
264
|
+
self.pagination_container.content = self.build_pagination()
|
265
|
+
if self.app and hasattr(self.app, 'page'):
|
266
|
+
self.app.page.update()
|
267
|
+
|
268
|
+
def set_loading(self, loading: bool):
|
269
|
+
"""Set loading state and update UI"""
|
270
|
+
self.is_loading = loading
|
271
|
+
if hasattr(self, 'loading_container'):
|
272
|
+
self.loading_container.visible = loading
|
273
|
+
self.update_items_display()
|
274
|
+
|
275
|
+
def refresh_page_state(self):
|
276
|
+
"""Clear page state and reload data. Called when navigating to this page."""
|
277
|
+
# Reset state
|
278
|
+
self.items = []
|
279
|
+
self.filtered_items = []
|
280
|
+
self.current_page = 0
|
281
|
+
self.search_query = ""
|
282
|
+
self.format_filter = "All"
|
283
|
+
|
284
|
+
# Reset UI elements if they exist
|
285
|
+
if hasattr(self, 'search_field'):
|
286
|
+
self.search_field.value = ""
|
287
|
+
if hasattr(self, 'format_dropdown'):
|
288
|
+
self.format_dropdown.value = "All"
|
289
|
+
|
290
|
+
# Reload data
|
291
|
+
self.load_items()
|