1086 lines
39 KiB
Python
1086 lines
39 KiB
Python
import flet as ft
|
|
import json
|
|
import requests
|
|
import yaml
|
|
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(
|
|
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
)
|
|
logger = logging.getLogger("flashcard-creator")
|
|
|
|
|
|
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"
|
|
self.base_url = ""
|
|
self.api_key = ""
|
|
self.load_settings()
|
|
|
|
# Languages
|
|
self.source_language = "English"
|
|
self.target_language = "German"
|
|
self.languages = [
|
|
"English",
|
|
"German",
|
|
"French",
|
|
"Spanish",
|
|
"Italian",
|
|
"Chinese",
|
|
"Japanese",
|
|
"Russian",
|
|
]
|
|
|
|
# Flashcards
|
|
self.flashcards = {}
|
|
|
|
def load_settings(self):
|
|
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)
|
|
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)
|
|
# 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
|
|
|
|
def build_ui(self, page: ft.Page):
|
|
page.title = "Flashcard Creator"
|
|
page.theme_mode = ft.ThemeMode.LIGHT
|
|
|
|
# Language selection dropdowns
|
|
self.source_dropdown = ft.Dropdown(
|
|
label="Source Language",
|
|
options=[ft.dropdown.Option(lang) for lang in self.languages],
|
|
value=self.source_language,
|
|
width=200,
|
|
on_change=self.on_source_language_change,
|
|
)
|
|
|
|
self.target_dropdown = ft.Dropdown(
|
|
label="Target Language",
|
|
options=[ft.dropdown.Option(lang) for lang in self.languages],
|
|
value=self.target_language,
|
|
width=200,
|
|
on_change=self.on_target_language_change,
|
|
)
|
|
|
|
# Text input for content to process
|
|
self.input_text = ft.TextField(
|
|
label="Input Text", multiline=True, min_lines=5, max_lines=15, width=600
|
|
)
|
|
|
|
# Process button
|
|
self.process_button = ft.ElevatedButton(
|
|
text="Process Text", on_click=self.on_process_text
|
|
)
|
|
|
|
# Settings button
|
|
self.settings_button = ft.ElevatedButton(
|
|
text="Settings", on_click=self.open_settings
|
|
)
|
|
|
|
# Stack name and description
|
|
self.stack_name = ft.TextField(label="Stack Name", width=300)
|
|
|
|
self.stack_description = ft.TextField(
|
|
label="Stack Description",
|
|
multiline=True,
|
|
min_lines=2,
|
|
max_lines=4,
|
|
width=600,
|
|
)
|
|
|
|
# Action buttons
|
|
self.add_to_anki_button = ft.ElevatedButton(
|
|
text="Add to Anki", on_click=self.add_to_anki
|
|
)
|
|
|
|
self.export_yaml_button = ft.ElevatedButton(
|
|
text="Export as YAML", on_click=self.export_as_yaml
|
|
)
|
|
|
|
self.export_csv_button = ft.ElevatedButton(
|
|
text="Export as CSV", on_click=self.export_as_csv
|
|
)
|
|
|
|
# Flashcard data table - make it expand to fill available width
|
|
self.flashcard_table = ft.DataTable(
|
|
columns=[
|
|
ft.DataColumn(ft.Text("Front")),
|
|
ft.DataColumn(ft.Text("Back")),
|
|
ft.DataColumn(ft.Text("Actions")),
|
|
],
|
|
rows=[],
|
|
expand=True, # Make table expand to fill available width
|
|
column_spacing=10, # Space between columns
|
|
horizontal_lines=ft.border.BorderSide(1, ft.colors.OUTLINE),
|
|
vertical_lines=ft.border.BorderSide(1, ft.colors.OUTLINE),
|
|
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.Row(
|
|
controls=[
|
|
ft.Column(
|
|
expand=True,
|
|
controls=[self.flashcard_table],
|
|
scroll=ft.ScrollMode.ALWAYS,
|
|
)
|
|
],
|
|
expand=True,
|
|
),
|
|
padding=10,
|
|
expand=True,
|
|
border=ft.border.all(1, ft.colors.OUTLINE),
|
|
border_radius=5,
|
|
)
|
|
|
|
# Add file picker to page overlay
|
|
page.overlay.append(self.file_picker)
|
|
|
|
# Create a scrollable column for the main content
|
|
main_content = ft.Column(
|
|
[
|
|
ft.Row(
|
|
[self.source_dropdown, self.target_dropdown, self.settings_button]
|
|
),
|
|
ft.Divider(),
|
|
ft.Text("Input Text", size=16, weight=ft.FontWeight.BOLD),
|
|
self.input_text,
|
|
self.process_button,
|
|
ft.Divider(),
|
|
ft.Text("Flashcard Information", size=16, weight=ft.FontWeight.BOLD),
|
|
self.stack_name,
|
|
self.stack_description,
|
|
ft.Divider(),
|
|
ft.Text("Generated Flashcards", size=16, weight=ft.FontWeight.BOLD),
|
|
flashcard_table_container,
|
|
ft.Row(
|
|
[
|
|
self.add_to_anki_button,
|
|
self.export_yaml_button,
|
|
self.export_csv_button,
|
|
]
|
|
),
|
|
],
|
|
scroll=ft.ScrollMode.AUTO,
|
|
expand=True,
|
|
)
|
|
|
|
# Add the scrollable content to the page
|
|
page.add(main_content)
|
|
|
|
def on_source_language_change(self, e):
|
|
self.source_language = e.control.value
|
|
|
|
def on_target_language_change(self, e):
|
|
self.target_language = e.control.value
|
|
|
|
def open_settings(self, e):
|
|
print("opening settings..")
|
|
self.load_settings()
|
|
|
|
# Create a settings dialog
|
|
def close_dialog_save(e):
|
|
self.llm_provider = provider_dropdown.value
|
|
self.llm_model = model_input.value
|
|
self.api_key = api_key_input.value
|
|
self.base_url = base_url_input.value
|
|
|
|
# Save settings to local storage
|
|
settings_dir = os.path.join(os.path.expanduser("~"), ".flashcard_creator")
|
|
os.makedirs(settings_dir, exist_ok=True)
|
|
|
|
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"
|
|
)
|
|
settings = {}
|
|
if os.path.exists(settings_path):
|
|
with open(settings_path, "r") as f:
|
|
try:
|
|
settings = json.load(f)
|
|
print(settings)
|
|
self.llm_provider = settings.get("provider", self.llm_provider)
|
|
# 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(
|
|
label="LLM Provider",
|
|
options=[
|
|
ft.dropdown.Option("openai"),
|
|
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)
|
|
base_url_input = ft.TextField(label="Base URL (optional)", value=self.base_url)
|
|
|
|
api_key_input = ft.TextField(label="API Key", value=self.api_key, password=True)
|
|
|
|
dialog = ft.AlertDialog(
|
|
title=ft.Text("Settings"),
|
|
content=ft.Column(
|
|
[provider_dropdown, model_input, base_url_input, api_key_input],
|
|
width=400,
|
|
height=400,
|
|
),
|
|
actions=[
|
|
ft.TextButton("Cancel", on_click=lambda e: e.page.close(dialog)),
|
|
ft.TextButton("Save", on_click=close_dialog_save),
|
|
],
|
|
)
|
|
|
|
e.page.open(dialog)
|
|
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(
|
|
ft.SnackBar(content=ft.Text("Please enter some text to process"))
|
|
)
|
|
return
|
|
|
|
logger.info("Processing input text")
|
|
|
|
self.is_processing = True
|
|
self.process_button.disabled = True
|
|
e.page.update()
|
|
|
|
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."""
|
|
from litellm import completion
|
|
|
|
# Show progress bar
|
|
progress = None
|
|
if page:
|
|
progress = ft.ProgressBar(width=max(page.width - 40, 200))
|
|
page.add(
|
|
ft.Column(
|
|
[
|
|
ft.Text(
|
|
f"Generating flashcards using {self.llm_provider}/{self.llm_model}",
|
|
size=16,
|
|
),
|
|
progress,
|
|
]
|
|
)
|
|
)
|
|
page.update()
|
|
|
|
logger.info(
|
|
f"Generating flashcards: {self.source_language} -> {self.target_language}"
|
|
)
|
|
logger.info(f"Using model: {self.llm_provider}/{self.llm_model}")
|
|
|
|
prompt = f"""You are a language learning assistant creating flashcards to help students learn {self.target_language} from {self.source_language}.
|
|
|
|
TASK: Create effective flashcards from the provided text.
|
|
|
|
INSTRUCTIONS:
|
|
1. Extract important vocabulary, phrases, and concepts from the text
|
|
2. Create flashcards with {self.source_language} on the front and {self.target_language} on the back
|
|
3. For vocabulary words:
|
|
- Include articles for nouns (e.g., "der/die/das" in German)
|
|
- Include plural forms where appropriate
|
|
- Include gender information for languages that have grammatical gender
|
|
4. For verbs, include the infinitive form and note if irregular
|
|
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:
|
|
```yaml
|
|
name: [Create a concise, descriptive name for this flashcard set]
|
|
description: [Write a brief description of the content covered]
|
|
cards:
|
|
- "[source term 1]": "[target translation 1]"
|
|
- "[source term 2]": "[target translation 2]"
|
|
- "[source phrase]": "[target translation with grammatical notes if needed]"
|
|
```
|
|
|
|
For example, for Chinese -> German, based on some vocab text including the following words,
|
|
your output could look like this:
|
|
|
|
```yaml
|
|
name: Basic Chinese to German Vocabulary
|
|
description: Essential vocabulary and phrases translated from Chinese to German
|
|
cards:
|
|
- "你好": "Hallo"
|
|
- "谢谢": "Danke"
|
|
- "再见": "Auf Wiedersehen"
|
|
- "苹果": "der Apfel (pl. die Äpfel)"
|
|
- "书": "das Buch (pl. die Bücher)"
|
|
- "学生": "der/die Student/Studentin (pl. die Studenten/Studentinnen)"
|
|
- "喝水": "Wasser trinken (verb, regular)"
|
|
- "吃饭": "essen (verb, irregular: isst, aß, gegessen)"
|
|
- "我很高兴认识你": "Es freut mich, dich kennenzulernen"
|
|
- "慢走": "Komm gut nach Hause (idiom.)"
|
|
```
|
|
|
|
Here's the text to process:
|
|
|
|
{text}
|
|
"""
|
|
|
|
try:
|
|
# Set the API key for the selected provider
|
|
# 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...")
|
|
|
|
# Use LiteLLM to generate flashcards
|
|
response = completion(
|
|
model=f"{self.llm_provider}/{self.llm_model}",
|
|
api_base=self.base_url if self.base_url else None,
|
|
messages=[{"role": "user", "content": prompt}],
|
|
)
|
|
|
|
logger.info("Received response from LLM API")
|
|
|
|
# Extract the YAML content from the response
|
|
yaml_content = response.choices[0].message.content
|
|
|
|
logger.info("Extracting YAML content from response")
|
|
|
|
# Extract YAML content from code blocks if present
|
|
if "```yaml" in yaml_content:
|
|
# Extract content between yaml and the next
|
|
yaml_content = (
|
|
yaml_content.split("```yaml", 1)[1].split("```")[0].strip()
|
|
)
|
|
elif "```" in yaml_content:
|
|
# Try to extract content between any
|
|
yaml_blocks = yaml_content.split("```")
|
|
if len(yaml_blocks) >= 3: # At least one complete code block
|
|
yaml_content = yaml_blocks[1].strip()
|
|
|
|
# Parse the YAML content
|
|
logger.info("Parsing YAML content")
|
|
flashcards = yaml.safe_load(yaml_content)
|
|
logger.info(
|
|
f"Generated {len(flashcards.get('cards', []))} flashcards successfully"
|
|
)
|
|
|
|
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
|
|
if page and progress:
|
|
page.controls.pop()
|
|
page.update()
|
|
|
|
def update_flashcard_table(self, flashcards: Dict, page: ft.Page):
|
|
"""Update the flashcard table with the generated flashcards."""
|
|
self.flashcards = flashcards
|
|
|
|
# Set the stack name and description
|
|
self.stack_name.value = flashcards.get("name", "")
|
|
self.stack_description.value = flashcards.get("description", "")
|
|
|
|
# Clear the existing rows
|
|
self.flashcard_table.rows.clear()
|
|
|
|
# Add a row for each flashcard
|
|
for card in flashcards.get("cards", []):
|
|
if isinstance(card, dict):
|
|
for front, back in card.items():
|
|
self.add_row_to_table(front, back)
|
|
else:
|
|
# Handle case where card is a string with format "front: back"
|
|
if ": " in card:
|
|
front, back = card.split(": ", 1)
|
|
self.add_row_to_table(front, back)
|
|
|
|
# Add a row for adding new flashcards
|
|
self.add_new_card_row()
|
|
|
|
# Update the page
|
|
page.update()
|
|
|
|
def add_row_to_table(self, front, back):
|
|
"""Add a row to the flashcard table."""
|
|
# Calculate width to make text fields take approximately 40% of screen width each
|
|
text_field_width = 400 # Adjust this value based on typical screen width
|
|
|
|
front_field = ft.TextField(
|
|
value=front,
|
|
width=text_field_width,
|
|
multiline=True, # Allow multiple lines for longer text
|
|
min_lines=1,
|
|
max_lines=3,
|
|
)
|
|
|
|
back_field = ft.TextField(
|
|
value=back,
|
|
width=text_field_width,
|
|
multiline=True, # Allow multiple lines for longer text
|
|
min_lines=1,
|
|
max_lines=3,
|
|
)
|
|
|
|
delete_button = ft.IconButton(
|
|
icon=ft.icons.DELETE,
|
|
on_click=lambda e, f=front_field, b=back_field: self.delete_flashcard(
|
|
e, f, b
|
|
),
|
|
)
|
|
|
|
self.flashcard_table.rows.append(
|
|
ft.DataRow(
|
|
cells=[
|
|
ft.DataCell(front_field),
|
|
ft.DataCell(back_field),
|
|
ft.DataCell(delete_button),
|
|
]
|
|
)
|
|
)
|
|
|
|
def add_new_card_row(self):
|
|
"""Add a row for adding new flashcards."""
|
|
# Calculate width to make text fields take approximately 40% of screen width each
|
|
text_field_width = 400 # Adjust this value based on typical screen width
|
|
|
|
new_front_field = ft.TextField(
|
|
hint_text="New Front",
|
|
width=text_field_width,
|
|
multiline=True,
|
|
min_lines=1,
|
|
max_lines=3,
|
|
)
|
|
|
|
new_back_field = ft.TextField(
|
|
hint_text="New Back",
|
|
width=text_field_width,
|
|
multiline=True,
|
|
min_lines=1,
|
|
max_lines=3,
|
|
)
|
|
|
|
add_button = ft.IconButton(
|
|
icon=ft.icons.ADD,
|
|
on_click=lambda e: self.add_flashcard(e, new_front_field, new_back_field),
|
|
)
|
|
|
|
self.flashcard_table.rows.append(
|
|
ft.DataRow(
|
|
cells=[
|
|
ft.DataCell(new_front_field),
|
|
ft.DataCell(new_back_field),
|
|
ft.DataCell(add_button),
|
|
]
|
|
)
|
|
)
|
|
|
|
def delete_flashcard(self, e, front_field, back_field):
|
|
"""Delete a flashcard from the table."""
|
|
for i, row in enumerate(self.flashcard_table.rows):
|
|
if (
|
|
row.cells[0].content == front_field
|
|
and row.cells[1].content == back_field
|
|
):
|
|
self.flashcard_table.rows.pop(i)
|
|
e.page.update()
|
|
return
|
|
|
|
def add_flashcard(self, e, front_field, back_field):
|
|
"""Add a new flashcard to the table."""
|
|
if not front_field.value or not back_field.value:
|
|
e.page.open(
|
|
ft.SnackBar(content=ft.Text("Please enter both front and back values"))
|
|
)
|
|
e.page.update()
|
|
return
|
|
|
|
# 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)
|
|
|
|
# re-add add_new_card_row to the end
|
|
self.add_new_card_row()
|
|
|
|
front_field.value = ""
|
|
back_field.value = ""
|
|
|
|
e.page.update()
|
|
|
|
def add_to_anki(self, e):
|
|
"""Add the flashcards to Anki using AnkiConnect."""
|
|
# Collect the flashcards from the table
|
|
cards = []
|
|
for row in self.flashcard_table.rows[:-1]: # Exclude the last row (add row)
|
|
front = row.cells[0].content.value
|
|
back = row.cells[1].content.value
|
|
if front and back:
|
|
cards.append({"front": front, "back": back})
|
|
|
|
if not cards:
|
|
e.page.open(ft.SnackBar(content=ft.Text("No flashcards to add")))
|
|
return
|
|
|
|
# Create a dialog for selecting the Anki deck
|
|
decks = self.get_anki_decks()
|
|
if not decks:
|
|
e.page.open(
|
|
ft.SnackBar(
|
|
content=ft.Text("Failed to connect to Anki or no decks found")
|
|
)
|
|
)
|
|
e.page.snack_bar.open = True
|
|
e.page.update()
|
|
return
|
|
|
|
deck_dropdown = ft.Dropdown(
|
|
label="Select Deck",
|
|
options=[ft.dropdown.Option(deck) for deck in decks],
|
|
width=300,
|
|
)
|
|
|
|
def add_cards(e):
|
|
selected_deck = deck_dropdown.value
|
|
if not selected_deck:
|
|
e.page.open(ft.SnackBar(content=ft.Text("Please select a deck")))
|
|
return
|
|
|
|
# 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()
|
|
|
|
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
|
|
)
|
|
e.page.open(
|
|
ft.SnackBar(
|
|
content=ft.Text(f"An unexpected error occurred: {ex}"),
|
|
open=True,
|
|
)
|
|
)
|
|
|
|
dialog = ft.AlertDialog(
|
|
title=ft.Text("Add to Anki"),
|
|
content=ft.Column([deck_dropdown], width=400, height=100),
|
|
actions=[
|
|
ft.TextButton(
|
|
"Cancel", on_click=lambda e: setattr(dialog, "open", False)
|
|
),
|
|
ft.TextButton("Add", on_click=add_cards),
|
|
],
|
|
)
|
|
|
|
e.page.open(dialog)
|
|
e.page.update()
|
|
|
|
def get_anki_decks(self) -> List[str]:
|
|
"""Get the list of decks from Anki using AnkiConnect."""
|
|
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]]
|
|
) -> 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."""
|
|
# Collect the flashcards from the table
|
|
cards = []
|
|
for row in self.flashcard_table.rows[:-1]: # Exclude the last row (add row)
|
|
front = row.cells[0].content.value
|
|
back = row.cells[1].content.value
|
|
if front and back:
|
|
cards.append({front: back})
|
|
|
|
if not cards:
|
|
e.page.open(ft.SnackBar(content=ft.Text("No flashcards to export")))
|
|
return
|
|
|
|
# Create the YAML content
|
|
yaml_content = {
|
|
"name": self.stack_name.value,
|
|
"description": self.stack_description.value,
|
|
"cards": cards,
|
|
}
|
|
|
|
# Create a file picker
|
|
def save_file(e: ft.FilePickerResultEvent):
|
|
if e.path:
|
|
try:
|
|
with open(e.path, "w", encoding="utf-8") as f:
|
|
yaml.dump(yaml_content, f, allow_unicode=True)
|
|
e.page.open(ft.SnackBar(content=ft.Text(f"Saved to {e.path}")))
|
|
except Exception as ex:
|
|
e.page.open(
|
|
ft.SnackBar(content=ft.Text(f"Error saving file: {ex}"))
|
|
)
|
|
|
|
self.file_picker.on_result = save_file
|
|
e.page.update()
|
|
self.file_picker.save_file(
|
|
file_name=f"{self.stack_name.value or 'flashcards'}.yaml",
|
|
file_type=ft.FilePickerFileType.CUSTOM,
|
|
allowed_extensions=["yaml"],
|
|
)
|
|
|
|
def export_as_csv(self, e):
|
|
"""Export the flashcards as a CSV file."""
|
|
# Collect the flashcards from the table
|
|
cards = []
|
|
for row in self.flashcard_table.rows[:-1]: # Exclude the last row (add row)
|
|
front = row.cells[0].content.value
|
|
back = row.cells[1].content.value
|
|
if front and back:
|
|
cards.append([front, back])
|
|
|
|
if not cards:
|
|
e.page.open(ft.SnackBar(content=ft.Text("No flashcards to export")))
|
|
return
|
|
|
|
# Create a file picker
|
|
def save_file(e: ft.FilePickerResultEvent):
|
|
if e.path:
|
|
try:
|
|
with open(e.path, "w", encoding="utf-8", newline="") as f:
|
|
writer = csv.writer(f)
|
|
writer.writerow(["Front", "Back"])
|
|
writer.writerows(cards)
|
|
e.page.open(ft.SnackBar(content=ft.Text(f"Saved to {e.path}")))
|
|
except Exception as ex:
|
|
e.page.open(
|
|
ft.SnackBar(content=ft.Text(f"Error saving file: {ex}"))
|
|
)
|
|
|
|
self.file_picker.on_result = save_file
|
|
e.page.update()
|
|
self.file_picker.save_file(
|
|
file_name=f"{self.stack_name.value or 'flashcards'}.csv",
|
|
file_type=ft.FilePickerFileType.CUSTOM,
|
|
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()
|
|
app.build_ui(page)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
ft.app(target=main)
|