tbr-deal-finder 0.2.0__py3-none-any.whl → 0.3.2__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 +754 -0
  9. tbr_deal_finder/gui/pages/__init__.py +1 -0
  10. tbr_deal_finder/gui/pages/all_books.py +100 -0
  11. tbr_deal_finder/gui/pages/all_deals.py +63 -0
  12. tbr_deal_finder/gui/pages/base_book_page.py +290 -0
  13. tbr_deal_finder/gui/pages/book_details.py +604 -0
  14. tbr_deal_finder/gui/pages/latest_deals.py +376 -0
  15. tbr_deal_finder/gui/pages/settings.py +390 -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 +69 -37
  26. tbr_deal_finder/tracked_books.py +76 -8
  27. tbr_deal_finder/utils.py +74 -3
  28. tbr_deal_finder/version_check.py +40 -0
  29. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.2.dist-info}/METADATA +19 -88
  30. tbr_deal_finder-0.3.2.dist-info/RECORD +38 -0
  31. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.2.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.2.dist-info}/WHEEL +0 -0
  34. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1 @@
1
+ # GUI pages package
@@ -0,0 +1,100 @@
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
+ # Disable navigation during the loading operation
43
+ self.app.disable_navigation()
44
+
45
+ try:
46
+ # Run the async operation directly
47
+ await self.app.auth_all_configured_retailers()
48
+ self.items = await get_tbr_books(self.app.config)
49
+ self.apply_filters()
50
+ except Exception as e:
51
+ logger.error(f"Error loading TBR books: {e}")
52
+ self.items = []
53
+ self.filtered_items = []
54
+ finally:
55
+ self.set_loading(False)
56
+
57
+ # Re-enable navigation after the operation completes
58
+ self.app.enable_navigation()
59
+
60
+ # Update the page to reflect the loaded data
61
+ self.app.page.update()
62
+
63
+
64
+ def filter_by_format(self, items, format_filter: str):
65
+ """Custom format filter that includes 'Either Format' option"""
66
+ if format_filter == "E-Book":
67
+ return [item for item in items if item.format == BookFormat.EBOOK]
68
+ elif format_filter == "Audiobook":
69
+ return [item for item in items if item.format == BookFormat.AUDIOBOOK]
70
+ elif format_filter == "Either Format":
71
+ return [item for item in items if item.format == BookFormat.NA]
72
+ else:
73
+ return items
74
+
75
+ def create_item_tile(self, book: Book):
76
+ """Create a tile for a single book"""
77
+ # Truncate title if too long
78
+ title = book.title
79
+ if len(title) > 60:
80
+ title = f"{title[:60]}..."
81
+
82
+ return ft.Card(
83
+ content=ft.Container(
84
+ content=ft.ListTile(
85
+ title=ft.Text(title, weight=ft.FontWeight.BOLD),
86
+ subtitle=ft.Column([
87
+ ft.Text(f"by {book.authors}", color=ft.Colors.GREY_600),
88
+ ], spacing=2),
89
+ on_click=lambda e, b=book: self.app.show_book_details(b, b.format)
90
+ ),
91
+ padding=10,
92
+ on_click=lambda e, b=book: self.app.show_book_details(b, b.format)
93
+ )
94
+ )
95
+
96
+ def check_book_has_deals(self, book: Book) -> bool:
97
+ """Check if a book has active deals (simplified check)"""
98
+ # This is a simplified check - in a real implementation you might
99
+ # want to query the deals database to see if this book has active deals
100
+ 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,290 @@
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.Column(item_tiles, spacing=5),
146
+ border=ft.border.all(1, ft.Colors.OUTLINE),
147
+ border_radius=8,
148
+ padding=10
149
+ )
150
+
151
+ def build_pagination(self):
152
+ """Build pagination controls"""
153
+ if not self.filtered_items or self.is_loading:
154
+ return ft.Container()
155
+
156
+ total_pages = (len(self.filtered_items) + self.items_per_page - 1) // self.items_per_page
157
+
158
+ # Page info
159
+ start_item = self.current_page * self.items_per_page + 1
160
+ end_item = min((self.current_page + 1) * self.items_per_page, len(self.filtered_items))
161
+
162
+ page_info = ft.Text(
163
+ f"Showing {start_item}-{end_item} of {len(self.filtered_items)} items",
164
+ color=ft.Colors.GREY_600
165
+ )
166
+
167
+ # Navigation buttons
168
+ prev_button = ft.IconButton(
169
+ icon=ft.Icons.CHEVRON_LEFT,
170
+ on_click=self.prev_page,
171
+ disabled=self.current_page == 0
172
+ )
173
+
174
+ next_button = ft.IconButton(
175
+ icon=ft.Icons.CHEVRON_RIGHT,
176
+ on_click=self.next_page,
177
+ disabled=self.current_page >= total_pages - 1
178
+ )
179
+
180
+ page_number = ft.Text(
181
+ f"Page {self.current_page + 1} of {total_pages}",
182
+ weight=ft.FontWeight.BOLD
183
+ )
184
+
185
+ return ft.Row([
186
+ page_info,
187
+ ft.Row([prev_button, page_number, next_button], spacing=5)
188
+ ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN)
189
+
190
+ def apply_filters(self):
191
+ """Apply search and format filters"""
192
+ filtered = self.items
193
+
194
+ # Apply search filter
195
+ if self.search_query:
196
+ filtered = self.filter_by_search(filtered, self.search_query)
197
+
198
+ # Apply format filter
199
+ if self.format_filter != "All":
200
+ filtered = self.filter_by_format(filtered, self.format_filter)
201
+
202
+ # Apply custom sorting
203
+ self.filtered_items = self.sort_items(filtered)
204
+ self.current_page = 0 # Reset to first page when filters change
205
+
206
+ def filter_by_search(self, items: List[Book], query: str) -> List[Book]:
207
+ """Filter items by search query. Override if needed."""
208
+ query = query.lower()
209
+ return [
210
+ item for item in items
211
+ if query in item.title.lower() or query in str(item.normalized_authors)
212
+ ]
213
+
214
+ def filter_by_format(self, items: List[Book], format_filter: str) -> List[Book]:
215
+ """Filter items by format. Override if needed."""
216
+ if format_filter == "E-Book":
217
+ format_value = BookFormat.EBOOK
218
+ elif format_filter == "Audiobook":
219
+ format_value = BookFormat.AUDIOBOOK
220
+ else:
221
+ return items
222
+
223
+ return [item for item in items if item.format == format_value]
224
+
225
+ def sort_items(self, items: List[Book]) -> List[Book]:
226
+ """Sort items. Override to customize sorting."""
227
+ return sorted(items, key=lambda x: x.deal_id)
228
+
229
+ def on_search_change(self, e):
230
+ """Handle search query changes"""
231
+ self.search_query = e.control.value
232
+ self.apply_filters()
233
+ self.update_items_display()
234
+
235
+ def on_format_change(self, e):
236
+ """Handle format filter changes"""
237
+ self.format_filter = e.control.value
238
+ self.apply_filters()
239
+ self.update_items_display()
240
+
241
+ def refresh_items(self, e):
242
+ """Refresh the items list"""
243
+ self.load_items()
244
+ self.update_items_display()
245
+
246
+ def prev_page(self, e):
247
+ """Go to previous page"""
248
+ if self.current_page > 0:
249
+ self.current_page -= 1
250
+ self.update_items_display()
251
+
252
+ def next_page(self, e):
253
+ """Go to next page"""
254
+ total_pages = (len(self.filtered_items) + self.items_per_page - 1) // self.items_per_page
255
+ if self.current_page < total_pages - 1:
256
+ self.current_page += 1
257
+ self.update_items_display()
258
+
259
+ def update_items_display(self):
260
+ """Update only the items list and pagination, preserving search field state"""
261
+ if hasattr(self, 'items_container') and hasattr(self, 'pagination_container'):
262
+ self.items_container.content = self.build_items_list()
263
+ self.pagination_container.content = self.build_pagination()
264
+ if self.app and hasattr(self.app, 'page'):
265
+ self.app.page.update()
266
+
267
+ def set_loading(self, loading: bool):
268
+ """Set loading state and update UI"""
269
+ self.is_loading = loading
270
+ if hasattr(self, 'loading_container'):
271
+ self.loading_container.visible = loading
272
+ self.update_items_display()
273
+
274
+ def refresh_page_state(self):
275
+ """Clear page state and reload data. Called when navigating to this page."""
276
+ # Reset state
277
+ self.items = []
278
+ self.filtered_items = []
279
+ self.current_page = 0
280
+ self.search_query = ""
281
+ self.format_filter = "All"
282
+
283
+ # Reset UI elements if they exist
284
+ if hasattr(self, 'search_field'):
285
+ self.search_field.value = ""
286
+ if hasattr(self, 'format_dropdown'):
287
+ self.format_dropdown.value = "All"
288
+
289
+ # Reload data
290
+ self.load_items()