feat: more functionality and suchh

This commit is contained in:
Yandrik 2025-03-26 18:00:07 +01:00
parent 5d9139bafb
commit a6f4cef792
5 changed files with 482 additions and 76 deletions

View File

@ -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.

108
anki.py
View File

@ -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}"))

412
main.py
View File

@ -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()

View File

@ -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
View File

@ -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"