Compare commits
5 Commits
7002b4437e
...
a6f4cef792
Author | SHA1 | Date | |
---|---|---|---|
a6f4cef792 | |||
5d9139bafb | |||
c3926641af | |||
9a29db84ef | |||
35df3eaab8 |
@ -23,3 +23,20 @@ I always use uv to install stuff, not pip
|
||||
|
||||
use `uv add <package>` to install instead of `pip install <package>`.
|
||||
use `uv run <command>` 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.
|
125
anki.py
Normal file
125
anki.py
Normal file
@ -0,0 +1,125 @@
|
||||
from result import Result, Ok, Err
|
||||
import requests
|
||||
import logging
|
||||
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.
|
||||
Returns Ok(list_of_deck_names) on success, Err(AnkiError) on failure.
|
||||
"""
|
||||
try:
|
||||
response = requests.post(
|
||||
self.url, json={"action": "deckNames", "version": 6}
|
||||
)
|
||||
data = response.json()
|
||||
return Ok(data.get("result", []))
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Connection error getting Anki decks: {e}")
|
||||
return Err(ConnectionError(f"Failed to connect to Anki: {e}"))
|
||||
except Exception as e:
|
||||
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]],
|
||||
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.
|
||||
|
||||
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.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}"))
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating Anki cards: {e}")
|
||||
return Err(CardError(f"Failed to create cards: {e}"))
|
504
main.py
504
main.py
@ -6,6 +6,9 @@ import os
|
||||
import csv
|
||||
import logging
|
||||
from typing import Dict, List, Any
|
||||
from result import Ok, Result
|
||||
|
||||
from anki import AnkiError
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(
|
||||
@ -16,12 +19,16 @@ logger = logging.getLogger("flashcard-creator")
|
||||
|
||||
class FlashcardCreator:
|
||||
def __init__(self):
|
||||
# AnkiConnect configuration
|
||||
self.anki_connect_url = "http://127.0.0.1:8765"
|
||||
# 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"
|
||||
@ -59,7 +66,11 @@ class FlashcardCreator:
|
||||
self.llm_provider = settings.get("provider", self.llm_provider)
|
||||
self.llm_model = settings.get("model", self.llm_model)
|
||||
self.base_url = settings.get("base_url", self.base_url)
|
||||
self.api_key = settings.get("api_key", self.api_key)
|
||||
# Load provider-specific settings
|
||||
provider_settings = settings.get(self.llm_provider, {})
|
||||
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:
|
||||
pass
|
||||
|
||||
@ -138,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
|
||||
@ -209,21 +226,57 @@ class FlashcardCreator:
|
||||
settings_dir = os.path.join(os.path.expanduser("~"), ".flashcard_creator")
|
||||
os.makedirs(settings_dir, exist_ok=True)
|
||||
|
||||
with open(os.path.join(settings_dir, "settings.json"), "w") as f:
|
||||
print(self.base_url)
|
||||
json.dump(
|
||||
{
|
||||
"provider": self.llm_provider,
|
||||
"model": self.llm_model,
|
||||
"base_url": self.base_url,
|
||||
"api_key": self.api_key,
|
||||
},
|
||||
f,
|
||||
)
|
||||
settings_path = os.path.join(settings_dir, "settings.json")
|
||||
settings = {}
|
||||
if os.path.exists(settings_path):
|
||||
try:
|
||||
with open(settings_path, "r") as f:
|
||||
settings = json.load(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Update provider-specific settings
|
||||
settings[self.llm_provider] = {
|
||||
"model": self.llm_model,
|
||||
"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)
|
||||
|
||||
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"
|
||||
@ -235,10 +288,13 @@ class FlashcardCreator:
|
||||
settings = json.load(f)
|
||||
print(settings)
|
||||
self.llm_provider = settings.get("provider", self.llm_provider)
|
||||
self.llm_model = settings.get("model", self.llm_model)
|
||||
self.base_url = settings.get("base_url", self.base_url)
|
||||
self.api_key = settings.get("api_key", self.api_key)
|
||||
except:
|
||||
# Load provider-specific settings
|
||||
provider_settings = settings.get(self.llm_provider, {})
|
||||
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 e:
|
||||
logger.info(f"Settings couldn't be loaded: {e}")
|
||||
pass
|
||||
|
||||
provider_dropdown = ft.Dropdown(
|
||||
@ -248,8 +304,10 @@ class FlashcardCreator:
|
||||
ft.dropdown.Option("anthropic"),
|
||||
ft.dropdown.Option("vertexai"),
|
||||
ft.dropdown.Option("huggingface"),
|
||||
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)
|
||||
@ -271,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(
|
||||
@ -285,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."""
|
||||
@ -298,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()
|
||||
|
||||
@ -324,6 +405,7 @@ class FlashcardCreator:
|
||||
5. For idiomatic expressions, provide the closest equivalent in the target language
|
||||
6. Choose content that represents different difficulty levels
|
||||
7. Ensure translations are accurate and natural-sounding in the target language
|
||||
8. Make sure to create flashcards for ALL relevant words in the input! If you are unsure, err on the side of too many flashcards.
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Return your response in valid YAML format with the following structure:
|
||||
@ -354,7 +436,6 @@ class FlashcardCreator:
|
||||
- "我很高兴认识你": "Es freut mich, dich kennenzulernen"
|
||||
- "慢走": "Komm gut nach Hause (idiom.)"
|
||||
```
|
||||
|
||||
|
||||
Here's the text to process:
|
||||
|
||||
@ -363,7 +444,20 @@ class FlashcardCreator:
|
||||
|
||||
try:
|
||||
# Set the API key for the selected provider
|
||||
os.environ[f"{self.llm_provider.upper()}_API_KEY"] = self.api_key
|
||||
# Load provider-specific settings
|
||||
settings_path = os.path.join(
|
||||
os.path.expanduser("~"), ".flashcard_creator", "settings.json"
|
||||
)
|
||||
settings = {}
|
||||
if os.path.exists(settings_path):
|
||||
with open(settings_path, "r") as f:
|
||||
try:
|
||||
settings = json.load(f)
|
||||
except:
|
||||
pass
|
||||
provider_settings = settings.get(self.llm_provider, {})
|
||||
api_key = provider_settings.get("api_key", "")
|
||||
os.environ[f"{self.llm_provider.upper()}_API_KEY"] = api_key
|
||||
|
||||
logger.info("Sending request to LLM API...")
|
||||
|
||||
@ -403,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
|
||||
@ -532,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):
|
||||
@ -580,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(
|
||||
@ -613,42 +777,23 @@ class FlashcardCreator:
|
||||
|
||||
def get_anki_decks(self) -> List[str]:
|
||||
"""Get the list of decks from Anki using AnkiConnect."""
|
||||
try:
|
||||
response = requests.post(
|
||||
self.anki_connect_url, json={"action": "deckNames", "version": 6}
|
||||
)
|
||||
data = response.json()
|
||||
return data.get("result", [])
|
||||
except Exception as e:
|
||||
print(f"Error getting Anki decks: {e}")
|
||||
result = self.anki.get_decks()
|
||||
if result.is_ok():
|
||||
return result.ok_value
|
||||
else:
|
||||
logger.error(f"Error getting Anki decks: {result.err_value}")
|
||||
return []
|
||||
|
||||
def create_anki_cards(self, deck: str, cards: List[Dict[str, str]]) -> bool:
|
||||
"""Create flashcards in Anki using AnkiConnect."""
|
||||
try:
|
||||
# Prepare notes for addition
|
||||
notes = []
|
||||
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}"],
|
||||
}
|
||||
)
|
||||
|
||||
# Add notes to Anki
|
||||
response = requests.post(
|
||||
self.anki_connect_url,
|
||||
json={"action": "addNotes", "version": 6, "params": {"notes": notes}},
|
||||
)
|
||||
data = response.json()
|
||||
return all(id is not None for id in data.get("result", []))
|
||||
except Exception as e:
|
||||
print(f"Error creating Anki cards: {e}")
|
||||
return False
|
||||
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, 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."""
|
||||
@ -727,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()
|
||||
|
@ -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",
|
||||
]
|
||||
|
19
uv.lock
19
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"
|
||||
|
Loading…
Reference in New Issue
Block a user