diff --git a/DesignRequirements.md b/DesignRequirements.md index 1229475..6d56bcf 100644 --- a/DesignRequirements.md +++ b/DesignRequirements.md @@ -23,3 +23,20 @@ I always use uv to install stuff, not pip use `uv add ` to install instead of `pip install `. use `uv run ` to run commands, e.g. `uv run python hello.py`. + + +## Coding + +For dialogs and snackbars, do the following: + +```python +dialog = < create dialog > + +# to show +page.open(dialog) + +# to close +page.close(dialog) +``` + +Do NOT use `page.show_snack_bar or page.dialog = ...` as those don't exist in recent Flet versions anymore. \ No newline at end of file diff --git a/anki.py b/anki.py index 5d4f9a4..2728971 100644 --- a/anki.py +++ b/anki.py @@ -1,32 +1,44 @@ from result import Result, Ok, Err import requests import logging -from typing import List, Dict +from typing import List, Dict, Tuple, Any logger = logging.getLogger("flashcard-creator") + class AnkiError(Exception): """Base class for Anki-related errors""" + pass + class ConnectionError(AnkiError): """Error connecting to AnkiConnect""" + pass + class DeckError(AnkiError): """Error related to deck operations""" + pass + class CardError(AnkiError): """Error related to card operations""" + pass + class AnkiConnect: def __init__(self, url="http://127.0.0.1:8765"): self.url = url def get_decks(self) -> Result[List[str], AnkiError]: - """Get the list of decks from Anki using AnkiConnect.""" + """ + Get the list of decks from Anki using AnkiConnect. + Returns Ok(list_of_deck_names) on success, Err(AnkiError) on failure. + """ try: response = requests.post( self.url, json={"action": "deckNames", "version": 6} @@ -40,37 +52,71 @@ class AnkiConnect: logger.error(f"Error getting Anki decks: {e}") return Err(DeckError(f"Failed to get decks: {e}")) - def create_cards(self, deck: str, cards: List[Dict[str, str]]) -> Result[bool, AnkiError]: - """Create flashcards in Anki using AnkiConnect.""" - try: - # Prepare notes for addition - notes = [] - logger.info(f"Preparing to create {len(cards)} Anki cards in deck: {deck}") - for card in cards: - notes.append( - { - "deckName": deck, - "modelName": "Basic", # Using the basic model - "fields": {"Front": card["front"], "Back": card["back"]}, - "options": {"allowDuplicate": False}, - "tags": [f"{self.source_language}-{self.target_language}"], - } - ) + def create_cards( + self, + deck: str, + cards: List[Dict[str, str]], + source_language: str, + target_language: str, + ) -> Result[Tuple[int, List[Dict[str, Any]]], AnkiError]: + """Create flashcards in Anki using AnkiConnect, adding each note one by one. - # Add notes to Anki - logger.info("Sending request to AnkiConnect to add notes") - response = requests.post( - self.url, - json={"action": "addNotes", "version": 6, "params": {"notes": notes}}, - ) - data = response.json() - success = all(id is not None for id in data.get("result", [])) - if success: - logger.info("Successfully added all cards to Anki") - return Ok(True) + Returns: + Ok((successful_count, failed_cards_list)): Where failed_cards_list contains + dictionaries like {'card': original_card_dict, 'error': error_message}. + Err(ConnectionError): If connection to AnkiConnect fails. + Err(CardError): If a non-connection error occurs during the process. + """ + try: + logger.info(f"Preparing to create {len(cards)} Anki cards in deck: {deck}") + + failed_cards = [] + successful_count = 0 + + # Add each note individually + for card in cards: + note = { + "deckName": deck, + "modelName": "Basic", # Using the basic model + "fields": {"Front": card["front"], "Back": card["back"]}, + "options": {"allowDuplicate": False}, + "tags": [f"{source_language}-{target_language}"], + } + + # Send request for a single note + response = requests.post( + self.url, + json={"action": "addNote", "version": 6, "params": {"note": note}}, + ) + data = response.json() + + # Check if the note was added successfully + if data.get("error") is not None or data.get("result") is None: + error_msg = data.get("error", "Unknown error") + failed_cards.append({"card": card, "error": error_msg}) + logger.warning( + f"Failed to add card: {card['front']} - Error: {error_msg}" + ) + else: + successful_count += 1 + logger.debug(f"Successfully added card: {card['front']}") + + # Report results + if failed_cards: + logger.warning( + f"Some cards failed to be added to Anki. Added {successful_count}/{len(cards)}" + ) + elif successful_count > 0: + logger.info(f"Successfully added all {successful_count} cards to Anki") else: - logger.warning("Some cards failed to be added to Anki") - return Err(CardError("Some cards failed to be added")) + logger.info( + "No cards were added." + ) # Should ideally not happen if input list wasn't empty + + # Always return Ok with success count and failed list (which might be empty) + # The caller can decide if the presence of failed_cards constitutes an overall "error" state for the UI + return Ok((successful_count, failed_cards)) + except requests.exceptions.RequestException as e: logger.error(f"Connection error creating Anki cards: {e}") return Err(ConnectionError(f"Failed to connect to Anki: {e}")) diff --git a/main.py b/main.py index 36f0d8b..1d8bedc 100644 --- a/main.py +++ b/main.py @@ -6,7 +6,9 @@ import os import csv import logging from typing import Dict, List, Any -from result import Ok +from result import Ok, Result + +from anki import AnkiError # Set up logging logging.basicConfig( @@ -19,11 +21,14 @@ class FlashcardCreator: def __init__(self): # Anki configuration from anki import AnkiConnect + self.anki = AnkiConnect() # File picker self.file_picker = ft.FilePicker() + self.is_processing = False + # LiteLLM configuration self.llm_provider = "openai" self.llm_model = "gpt-3.5-turbo" @@ -144,19 +149,25 @@ class FlashcardCreator: width=page.width, # Make table as wide as the window ) + # Initialize the flashcard table with the "add new card" row + self.add_new_card_row() + # Wrap the table in a Container with specific height to ensure visibility flashcard_table_container = ft.Container( - content=ft.Column( + content=ft.Row( + controls=[ + ft.Column( + expand=True, + controls=[self.flashcard_table], + scroll=ft.ScrollMode.ALWAYS, + ) + ], expand=True, - controls=[self.flashcard_table], - scroll=ft.ScrollMode.ALWAYS, ), padding=10, expand=True, - # height=400, border=ft.border.all(1, ft.colors.OUTLINE), border_radius=5, - width=page.width, ) # Add file picker to page overlay @@ -230,6 +241,8 @@ class FlashcardCreator: "base_url": self.base_url, "api_key": self.api_key, } + # Save the currently selected provider + settings["provider"] = self.llm_provider with open(settings_path, "w") as f: json.dump(settings, f, indent=4) @@ -237,6 +250,33 @@ class FlashcardCreator: e.page.close(dialog) e.page.update() + # Function to update displayed settings when provider changes + def on_provider_change(e): + selected_provider = provider_dropdown.value + + # Load settings from file + settings_path = os.path.join( + os.path.expanduser("~"), ".flashcard_creator", "settings.json" + ) + settings = {} + if os.path.exists(settings_path): + try: + with open(settings_path, "r") as f: + settings = json.load(f) + except: + pass + + # Get selected provider's settings or empty dict if not found + provider_settings = settings.get(selected_provider, {}) + + # Update the input fields with the provider's settings + model_input.value = provider_settings.get("model", "") + base_url_input.value = provider_settings.get("base_url", "") + api_key_input.value = provider_settings.get("api_key", "") + + # Update the dialog + e.page.update() + # Load settings if they exist settings_path = os.path.join( os.path.expanduser("~"), ".flashcard_creator", "settings.json" @@ -253,7 +293,8 @@ class FlashcardCreator: self.llm_model = provider_settings.get("model", self.llm_model) self.base_url = provider_settings.get("base_url", self.base_url) self.api_key = provider_settings.get("api_key", self.api_key) - except: + except e: + logger.info(f"Settings couldn't be loaded: {e}") pass provider_dropdown = ft.Dropdown( @@ -266,6 +307,7 @@ class FlashcardCreator: ft.dropdown.Option("deepseek"), ], value=self.llm_provider, + on_change=on_provider_change, # Add the on_change handler ) model_input = ft.TextField(label="Model Name", value=self.llm_model) @@ -287,11 +329,16 @@ class FlashcardCreator: ) e.page.open(dialog) - # e.page.dialog = dialog - # dialog.open = True e.page.update() def on_process_text(self, e): + # Prevent multiple simultaneous processing + if self.is_processing: + e.page.open( + ft.SnackBar(content=ft.Text("Processing in progress, please wait...")) + ) + return + input_text = self.input_text.value if not input_text: e.page.open( @@ -301,11 +348,21 @@ class FlashcardCreator: logger.info("Processing input text") - # Use LiteLLM to process the text and generate flashcards - flashcards = self.generate_flashcards(input_text, e.page) + self.is_processing = True + self.process_button.disabled = True + e.page.update() - # Update the data table with the generated flashcards - self.update_flashcard_table(flashcards, e.page) + try: + # Use LiteLLM to process the text and generate flashcards + flashcards = self.generate_flashcards(input_text, e.page) + + # Update the data table with the generated flashcards + self.update_flashcard_table(flashcards, e.page) + finally: + # Reset processing state and re-enable button + self.is_processing = False + self.process_button.disabled = False + e.page.update() def generate_flashcards(self, text: str, page: ft.Page = None) -> Dict: """Use LiteLLM to generate flashcards from the input text.""" @@ -314,9 +371,17 @@ class FlashcardCreator: # Show progress bar progress = None if page: - progress = ft.ProgressBar(width=600) + progress = ft.ProgressBar(width=max(page.width - 40, 200)) page.add( - ft.Column([ft.Text("Generating flashcards...", size=16), progress]) + ft.Column( + [ + ft.Text( + f"Generating flashcards using {self.llm_provider}/{self.llm_model}", + size=16, + ), + progress, + ] + ) ) page.update() @@ -432,6 +497,12 @@ class FlashcardCreator: return flashcards except Exception as e: logger.error(f"Error generating flashcards: {e}", exc_info=True) + if page: + page.open( + ft.SnackBar( + content=ft.Text(f"Error generating flashcards: {str(e)}") + ) + ) return {"name": "", "description": "", "cards": []} finally: # Remove progress bar @@ -561,14 +632,19 @@ class FlashcardCreator: e.page.update() return - # Add a new row with the entered values + # remove add_new_card row + if self.flashcard_table.rows: + self.flashcard_table.rows.pop() + + # add card to table self.add_row_to_table(front_field.value, back_field.value) - # Clear the input fields + # re-add add_new_card_row to the end + self.add_new_card_row() + front_field.value = "" back_field.value = "" - # Update the page e.page.update() def add_to_anki(self, e): @@ -609,21 +685,80 @@ class FlashcardCreator: e.page.open(ft.SnackBar(content=ft.Text("Please select a deck"))) return - success = self.create_anki_cards(selected_deck, cards) + # Close the deck selection dialog first e.page.close(dialog) + e.page.update() # Update to ensure dialog is closed visually + + # Show a progress indicator while adding cards + progress_ring = ft.ProgressRing(width=16, height=16, stroke_width=2) + progress_snackbar = ft.SnackBar( + content=ft.Row( + [ + progress_ring, + ft.Text(f"Adding cards to {selected_deck}... Please wait."), + ] + ), + open=True, + duration=None, # Keep open until manually closed or replaced + ) + e.page.open(progress_snackbar) e.page.update() - if success: - e.page.open( - ft.SnackBar( - content=ft.Text( - f"Added {len(cards)} flashcards to {selected_deck}" + try: + result = self.create_anki_cards(selected_deck, cards) + + # Close the progress snackbar + e.page.close(progress_snackbar) + e.page.update() + + if result.is_ok(): + successful_count, failed_cards_list = result.ok_value + total_attempted = len(cards) + + if not failed_cards_list: + # All cards added successfully + e.page.open( + ft.SnackBar( + content=ft.Text( + f"Successfully added {successful_count}/{total_attempted} flashcards to {selected_deck}" + ), + open=True, + ) + ) + else: + # Some cards failed, show the failure dialog + logger.warning( + f"Failed to add {len(failed_cards_list)} cards. Opening retry dialog." + ) + self.show_failed_cards_dialog( + e.page, + selected_deck, + failed_cards_list, + successful_count, + total_attempted, + ) + + else: # Handle Err case from create_anki_cards + e.page.open( + ft.SnackBar( + content=ft.Text( + f"An error occurred while adding cards to Anki: {result.err_value}" + ), + open=True, ) ) + except Exception as ex: # Catch any unexpected errors during the process + # Close the progress snackbar in case of an error + e.page.close(progress_snackbar) + e.page.update() + logger.error( + f"Unexpected error during Anki card addition: {ex}", exc_info=True ) - else: e.page.open( - ft.SnackBar(content=ft.Text("Failed to add flashcards to Anki")) + ft.SnackBar( + content=ft.Text(f"An unexpected error occurred: {ex}"), + open=True, + ) ) dialog = ft.AlertDialog( @@ -643,20 +778,22 @@ class FlashcardCreator: def get_anki_decks(self) -> List[str]: """Get the list of decks from Anki using AnkiConnect.""" result = self.anki.get_decks() - if isinstance(result, Ok): - return result.value + if result.is_ok(): + return result.ok_value else: - logger.error(f"Error getting Anki decks: {result.value}") + logger.error(f"Error getting Anki decks: {result.err_value}") return [] - def create_anki_cards(self, deck: str, cards: List[Dict[str, str]]) -> bool: + def create_anki_cards( + self, deck: str, cards: List[Dict[str, str]] + ) -> Result[None, AnkiError]: """Create flashcards in Anki using Anki-connect.""" - result = self.anki.create_cards(deck, cards) - if isinstance(result, Ok): - return result.value - else: - logger.error(f"Error creating Anki cards: {result.value}") - return False + result = self.anki.create_cards( + deck, cards, self.source_language, self.target_language + ) + if result.is_err(): + logger.error(f"Error creating Anki cards: {result.err_value}") + return result def export_as_yaml(self, e): """Export the flashcards as a YAML file.""" @@ -735,6 +872,209 @@ class FlashcardCreator: allowed_extensions=["csv"], ) + # --- Methods for Handling Failed Anki Card Additions --- + + def _build_failed_card_row( + self, page: ft.Page, card_data: Dict[str, Any] + ) -> ft.Row: + """Builds a Flet Row for displaying a failed card with editable fields.""" + front_field = ft.TextField( + value=card_data["card"]["front"], + width=300, + multiline=True, + min_lines=1, + max_lines=3, + ) + back_field = ft.TextField( + value=card_data["card"]["back"], + width=300, + multiline=True, + min_lines=1, + max_lines=3, + ) + error_text = ft.Text( + f"Error: {card_data['error']}", color=ft.colors.ERROR, size=12 + ) + + # Store references to the fields within the row for later access + row = ft.Row( + controls=[ + ft.Column([front_field], width=310), # Column to constrain width + ft.Column([back_field], width=310), # Column to constrain width + ft.Column( + [error_text], expand=True, alignment=ft.MainAxisAlignment.CENTER + ), + ], + alignment=ft.MainAxisAlignment.START, + ) + # Attach fields and original data to the row object itself for easy retrieval + row.front_field = front_field + row.back_field = back_field + row.card_data = card_data + return row + + def show_failed_cards_dialog( + self, + page: ft.Page, + deck: str, + failed_cards: List[Dict[str, Any]], + initial_success_count: int, + total_attempted: int, + ): + """Displays a dialog with failed Anki cards, allowing editing and retrying.""" + + failed_card_rows = [ + self._build_failed_card_row(page, card_data) for card_data in failed_cards + ] + + # Use ListView for scrollability if many cards fail + content_list = ft.ListView(controls=failed_card_rows, expand=True, spacing=10) + + dialog_title = ft.Text(f"Failed Cards for Deck: {deck}") + status_text = ft.Text( + f"{initial_success_count}/{total_attempted} added initially. {len(failed_cards)} failed." + ) + + # --- Retry Logic --- + def _retry_failed_cards(e): + cards_to_retry = [] + for ( + row + ) in content_list.controls: # Access controls directly from the ListView + if hasattr(row, "front_field") and hasattr( + row, "back_field" + ): # Ensure it's a card row + cards_to_retry.append( + {"front": row.front_field.value, "back": row.back_field.value} + ) + + if not cards_to_retry: + page.open( + ft.SnackBar( + content=ft.Text("No cards remaining to retry."), open=True + ) + ) + return + + # Disable button during retry + retry_button.disabled = True + page.update() + + # Show progress in dialog (optional, could use snackbar again) + # status_text.value = "Retrying..." + # page.update() + + try: + retry_result = self.create_anki_cards(deck, cards_to_retry) + + if retry_result.is_ok(): + retry_successful_count, still_failed_list = retry_result.ok_value + new_total_successful = ( + initial_success_count + retry_successful_count + ) + + # Update the dialog content: keep only the ones that *still* failed + new_failed_rows = [] + still_failed_cards_data = { + tuple(fc["card"].items()): fc for fc in still_failed_list + } # For quick lookup + + updated_failed_cards_list = [] # Keep track of data for next retry + for row in content_list.controls: + if hasattr(row, "card_data"): + original_card_tuple = tuple(row.card_data["card"].items()) + if original_card_tuple in still_failed_cards_data: + # Update error message if it changed (though unlikely for duplicates) + row.card_data["error"] = still_failed_cards_data[ + original_card_tuple + ]["error"] + # Rebuild or update the error text in the row if needed + # For simplicity, we'll just keep the row + new_failed_rows.append(row) + updated_failed_cards_list.append(row.card_data) + + content_list.controls = ( + new_failed_rows # Replace controls in ListView + ) + status_text.value = f"{new_total_successful}/{total_attempted} added. {len(new_failed_rows)} still failing." + + if not new_failed_rows: + # All retried cards were successful! Close dialog. + page.close(dialog) + page.open( + ft.SnackBar( + content=ft.Text( + f"All cards successfully added! ({new_total_successful}/{total_attempted})" + ), + open=True, + ) + ) + else: + # Some still failed, update dialog view + page.open( + ft.SnackBar( + content=ft.Text( + f"Successfully retried {retry_successful_count} cards. {len(new_failed_rows)} still failed." + ), + open=True, + ) + ) + + else: # Handle Err from retry attempt + status_text.value = f"Retry Error: {retry_result.err_value}" + page.open( + ft.SnackBar( + content=ft.Text( + f"Error during retry: {retry_result.err_value}" + ), + open=True, + ) + ) + + except Exception as ex: + logger.error( + f"Unexpected error during Anki card retry: {ex}", exc_info=True + ) + status_text.value = f"Unexpected Retry Error: {ex}" + page.open( + ft.SnackBar( + content=ft.Text( + f"An unexpected error occurred during retry: {ex}" + ), + open=True, + ) + ) + finally: + # Re-enable button + retry_button.disabled = ( + False if content_list.controls else True + ) # Disable if list is empty + page.update() # Update UI (dialog content, status text, button state) + + # --- Dialog Buttons --- + retry_button = ft.TextButton( + "Retry Failed Cards", + on_click=_retry_failed_cards, + disabled=not failed_card_rows, + ) + close_button = ft.TextButton("Close", on_click=lambda e: page.close(dialog)) + + dialog = ft.AlertDialog( + modal=True, + title=dialog_title, + content=ft.Container( # Use Container for better sizing control + content=ft.Column([status_text, content_list], tight=True), + width=max(page.width * 0.8, 700), # Make dialog wider + height=max(page.height * 0.7, 400), # Make dialog taller + padding=10, + ), + actions=[retry_button, close_button], + actions_alignment=ft.MainAxisAlignment.END, + ) + + page.open(dialog) + page.update() + def main(page: ft.Page): app = FlashcardCreator() diff --git a/pyproject.toml b/pyproject.toml index c42c49a..09908e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,5 +7,5 @@ requires-python = ">=3.12" dependencies = [ "flet[all]>=0.27.6", "litellm>=1.63.12", - "logging>=0.4.9.6", + "result>=0.17.0", ] diff --git a/uv.lock b/uv.lock index 6cfeb5c..55f3ab0 100644 --- a/uv.lock +++ b/uv.lock @@ -574,12 +574,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/01/6f2fb287b93144851bfd442b265a67933bbd677e6dcbfa9951063199cf94/litellm-1.63.12-py3-none-any.whl", hash = "sha256:ae72a9d7099100b4b1172aaa2954bf6d7b205d47ba76beec5cd53f62dd57913e", size = 6954945 }, ] -[[package]] -name = "logging" -version = "0.4.9.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/93/4b/979db9e44be09f71e85c9c8cfc42f258adfb7d93ce01deed2788b2948919/logging-0.4.9.6.tar.gz", hash = "sha256:26f6b50773f085042d301085bd1bf5d9f3735704db9f37c1ce6d8b85c38f2417", size = 96029 } - [[package]] name = "markdown-it-py" version = "3.0.0" @@ -646,14 +640,14 @@ source = { virtual = "." } dependencies = [ { name = "flet", extra = ["all"] }, { name = "litellm" }, - { name = "logging" }, + { name = "result" }, ] [package.metadata] requires-dist = [ { name = "flet", extras = ["all"], specifier = ">=0.27.6" }, { name = "litellm", specifier = ">=1.63.12" }, - { name = "logging", specifier = ">=0.4.9.6" }, + { name = "result", specifier = ">=0.17.0" }, ] [[package]] @@ -1027,6 +1021,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, ] +[[package]] +name = "result" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/47/2175be65744aa4d8419c27bd3a7a7d65af5bcad7a4dc6a812c00778754f0/result-0.17.0.tar.gz", hash = "sha256:b73da420c0cb1a3bf741dbd41ff96dedafaad6a1b3ef437a9e33e380bb0d91cf", size = 20180 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/90/19110ce9374c3db619e2df0816f2c58e4ddc5cdad5f7284cd81d8b30b7cb/result-0.17.0-py3-none-any.whl", hash = "sha256:49fd668b4951ad15800b8ccefd98b6b94effc789607e19c65064b775570933e8", size = 11689 }, +] + [[package]] name = "rich" version = "13.9.4"