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.
Files changed (34) hide show
  1. tbr_deal_finder/__init__.py +1 -5
  2. tbr_deal_finder/__main__.py +7 -0
  3. tbr_deal_finder/book.py +28 -8
  4. tbr_deal_finder/cli.py +15 -28
  5. tbr_deal_finder/config.py +3 -3
  6. tbr_deal_finder/desktop_updater.py +147 -0
  7. tbr_deal_finder/gui/__init__.py +0 -0
  8. tbr_deal_finder/gui/main.py +725 -0
  9. tbr_deal_finder/gui/pages/__init__.py +1 -0
  10. tbr_deal_finder/gui/pages/all_books.py +93 -0
  11. tbr_deal_finder/gui/pages/all_deals.py +63 -0
  12. tbr_deal_finder/gui/pages/base_book_page.py +291 -0
  13. tbr_deal_finder/gui/pages/book_details.py +604 -0
  14. tbr_deal_finder/gui/pages/latest_deals.py +370 -0
  15. tbr_deal_finder/gui/pages/settings.py +389 -0
  16. tbr_deal_finder/migrations.py +26 -0
  17. tbr_deal_finder/queries/latest_unknown_book_sync.sql +5 -0
  18. tbr_deal_finder/retailer/amazon.py +60 -9
  19. tbr_deal_finder/retailer/amazon_custom_auth.py +79 -0
  20. tbr_deal_finder/retailer/audible.py +2 -1
  21. tbr_deal_finder/retailer/chirp.py +55 -11
  22. tbr_deal_finder/retailer/kindle.py +68 -44
  23. tbr_deal_finder/retailer/librofm.py +58 -21
  24. tbr_deal_finder/retailer/models.py +31 -1
  25. tbr_deal_finder/retailer_deal.py +62 -21
  26. tbr_deal_finder/tracked_books.py +76 -8
  27. tbr_deal_finder/utils.py +64 -2
  28. tbr_deal_finder/version_check.py +40 -0
  29. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/METADATA +19 -88
  30. tbr_deal_finder-0.3.1.dist-info/RECORD +38 -0
  31. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/entry_points.txt +1 -0
  32. tbr_deal_finder-0.2.0.dist-info/RECORD +0 -24
  33. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/WHEEL +0 -0
  34. {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()