|
|
|
|
|
import gradio as gr |
|
import pandas as pd |
|
import genanki |
|
import random |
|
from typing import List, Dict, Any, Optional |
|
import csv |
|
from datetime import datetime |
|
import os |
|
|
|
from ankigen_core.utils import get_logger, strip_html_tags |
|
|
|
logger = get_logger() |
|
|
|
|
|
|
|
def _format_field_as_string(value: Any) -> str: |
|
if isinstance(value, list) or isinstance(value, tuple): |
|
return ", ".join(str(item).strip() for item in value if str(item).strip()) |
|
if pd.isna(value) or value is None: |
|
return "" |
|
return str(value).strip() |
|
|
|
|
|
|
|
ANKI_BASIC_MODEL_NAME = "AnkiGen Basic" |
|
ANKI_CLOZE_MODEL_NAME = "AnkiGen Cloze" |
|
|
|
|
|
|
|
DEFAULT_BASIC_MODEL_ID = random.randrange(1 << 30, 1 << 31) |
|
DEFAULT_CLOZE_MODEL_ID = random.randrange(1 << 30, 1 << 31) |
|
|
|
|
|
|
|
BASIC_MODEL = genanki.Model( |
|
DEFAULT_BASIC_MODEL_ID, |
|
ANKI_BASIC_MODEL_NAME, |
|
fields=[ |
|
{"name": "Question"}, |
|
{"name": "Answer"}, |
|
{"name": "Explanation"}, |
|
{"name": "Example"}, |
|
{"name": "Prerequisites"}, |
|
{"name": "Learning_Outcomes"}, |
|
{"name": "Common_Misconceptions"}, |
|
{"name": "Difficulty"}, |
|
{"name": "SourceURL"}, |
|
{"name": "TagsStr"}, |
|
], |
|
templates=[ |
|
{ |
|
"name": "Card 1", |
|
"qfmt": """ |
|
<div class=\"card question-side\"> |
|
<div class=\"difficulty-indicator {{Difficulty}}\"></div> |
|
<div class=\"content\"> |
|
<div class=\"question\">{{Question}}</div> |
|
<div class=\"prerequisites\" onclick=\"event.stopPropagation();\"> |
|
<div class=\"prerequisites-toggle\">Show Prerequisites</div> |
|
<div class=\"prerequisites-content\">{{Prerequisites}}</div> |
|
</div> |
|
</div> |
|
</div> |
|
<script> |
|
document.querySelector('.prerequisites-toggle').addEventListener('click', function(e) { |
|
e.stopPropagation(); |
|
this.parentElement.classList.toggle('show'); |
|
}); |
|
</script> |
|
""", |
|
"afmt": """ |
|
<div class=\"card answer-side\"> |
|
<div class=\"content\"> |
|
<div class=\"question-section\"> |
|
<div class=\"question\">{{Question}}</div> |
|
<div class=\"prerequisites\"> |
|
<strong>Prerequisites:</strong> {{Prerequisites}} |
|
</div> |
|
</div> |
|
<hr> |
|
|
|
<div class=\"answer-section\"> |
|
<h3>Answer</h3> |
|
<div class=\"answer\">{{Answer}}</div> |
|
</div> |
|
|
|
<div class=\"explanation-section\"> |
|
<h3>Explanation</h3> |
|
<div class=\"explanation-text\">{{Explanation}}</div> |
|
</div> |
|
|
|
<div class=\"example-section\"> |
|
<h3>Example</h3> |
|
<div class=\"example-text\">{{Example}}</div> |
|
<!-- Example field might contain pre/code or plain text --> |
|
<!-- Handled by how HTML is put into the Example field --> |
|
</div> |
|
|
|
<div class=\"metadata-section\"> |
|
<div class=\"learning-outcomes\"> |
|
<h3>Learning Outcomes</h3> |
|
<div>{{Learning_Outcomes}}</div> |
|
</div> |
|
|
|
<div class=\"misconceptions\"> |
|
<h3>Common Misconceptions - Debunked</h3> |
|
<div>{{Common_Misconceptions}}</div> |
|
</div> |
|
|
|
<div class=\"difficulty\"> |
|
<h3>Difficulty Level</h3> |
|
<div>{{Difficulty}}</div> |
|
</div> |
|
{{#SourceURL}}<div class=\"source-url\"><small>Source: <a href=\"{{SourceURL}}\">{{SourceURL}}</a></small></div>{{/SourceURL}} |
|
</div> |
|
</div> |
|
</div> |
|
""", |
|
} |
|
], |
|
css=""" |
|
/* Base styles */ |
|
.card { |
|
font-family: 'Inter', system-ui, -apple-system, sans-serif; |
|
font-size: 16px; |
|
line-height: 1.6; |
|
color: #1a1a1a; |
|
max-width: 800px; |
|
margin: 0 auto; |
|
padding: 20px; |
|
background: #ffffff; |
|
} |
|
|
|
@media (max-width: 768px) { |
|
.card { |
|
font-size: 14px; |
|
padding: 15px; |
|
} |
|
} |
|
|
|
/* Question side */ |
|
.question-side { |
|
position: relative; |
|
min-height: 200px; |
|
} |
|
|
|
.difficulty-indicator { |
|
position: absolute; |
|
top: 10px; |
|
right: 10px; |
|
width: 10px; |
|
height: 10px; |
|
border-radius: 50%; |
|
} |
|
|
|
.difficulty-indicator.beginner { background: #4ade80; } |
|
.difficulty-indicator.intermediate { background: #fbbf24; } |
|
.difficulty-indicator.advanced { background: #ef4444; } |
|
|
|
.question { |
|
font-size: 1.3em; |
|
font-weight: 600; |
|
color: #2563eb; |
|
margin-bottom: 1.5em; |
|
} |
|
|
|
.prerequisites { |
|
margin-top: 1em; |
|
font-size: 0.9em; |
|
color: #666; |
|
} |
|
|
|
.prerequisites-toggle { |
|
color: #2563eb; |
|
cursor: pointer; |
|
text-decoration: underline; |
|
} |
|
|
|
.prerequisites-content { |
|
display: none; |
|
margin-top: 0.5em; |
|
padding: 0.5em; |
|
background: #f8fafc; |
|
border-radius: 4px; |
|
} |
|
|
|
.prerequisites.show .prerequisites-content { |
|
display: block; |
|
} |
|
|
|
/* Answer side */ |
|
.answer-section, |
|
.explanation-section, |
|
.example-section { |
|
margin: 1.5em 0; |
|
padding: 1.2em; |
|
border-radius: 8px; |
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05); |
|
} |
|
|
|
.answer-section { |
|
background: #f0f9ff; |
|
border-left: 4px solid #2563eb; |
|
} |
|
|
|
.explanation-section { |
|
background: #f0fdf4; |
|
border-left: 4px solid #4ade80; |
|
} |
|
|
|
.example-section { |
|
background: #fefce8; /* Light yellow */ |
|
border-left: 4px solid #facc15; /* Yellow */ |
|
} |
|
.example-section pre { |
|
background-color: #2d2d2d; /* Darker background for code blocks */ |
|
color: #f8f8f2; /* Light text for contrast */ |
|
padding: 1em; |
|
border-radius: 0.3em; |
|
overflow-x: auto; /* Horizontal scroll for long lines */ |
|
font-family: 'Consolas', 'Monaco', 'Menlo', monospace; |
|
font-size: 0.9em; |
|
line-height: 1.4; |
|
} |
|
|
|
.example-section code { |
|
font-family: 'Consolas', 'Monaco', 'Menlo', monospace; |
|
} |
|
|
|
.metadata-section { |
|
margin-top: 2em; |
|
padding-top: 1em; |
|
border-top: 1px solid #e5e7eb; /* Light gray border */ |
|
font-size: 0.9em; |
|
color: #4b5563; /* Cool gray */ |
|
} |
|
|
|
.metadata-section h3 { |
|
font-size: 1em; |
|
color: #1f2937; /* Darker gray for headings */ |
|
margin-bottom: 0.5em; |
|
} |
|
|
|
.metadata-section > div { |
|
margin-bottom: 0.8em; |
|
} |
|
|
|
.source-url a { |
|
color: #2563eb; |
|
text-decoration: none; |
|
} |
|
.source-url a:hover { |
|
text-decoration: underline; |
|
} |
|
|
|
/* Styles for cloze deletion cards */ |
|
.cloze { |
|
font-weight: bold; |
|
color: blue; |
|
} |
|
.nightMode .cloze { |
|
color: lightblue; |
|
} |
|
|
|
/* General utility */ |
|
hr { |
|
border: none; |
|
border-top: 1px dashed #cbd5e1; /* Light dashed line */ |
|
margin: 1.5em 0; |
|
} |
|
|
|
/* Rich text field styling (if Anki adds classes for these) */ |
|
.field ul, .field ol { |
|
margin-left: 1.5em; |
|
padding-left: 0.5em; |
|
} |
|
.field li { |
|
margin-bottom: 0.3em; |
|
} |
|
|
|
/* Responsive design */ |
|
@media (max-width: 640px) { |
|
.answer-section, |
|
.explanation-section, |
|
.example-section { |
|
padding: 1em; |
|
margin: 1em 0; |
|
} |
|
} |
|
|
|
/* Animations */ |
|
@keyframes fadeIn { |
|
from { opacity: 0; } |
|
to { opacity: 1; } |
|
} |
|
|
|
.card { |
|
animation: fadeIn 0.3s ease-in-out; |
|
} |
|
""", |
|
|
|
|
|
) |
|
|
|
CLOZE_MODEL = genanki.Model( |
|
DEFAULT_CLOZE_MODEL_ID, |
|
ANKI_CLOZE_MODEL_NAME, |
|
fields=[ |
|
{"name": "Text"}, |
|
{"name": "Back Extra"}, |
|
{"name": "Explanation"}, |
|
{"name": "Example"}, |
|
{"name": "Prerequisites"}, |
|
{"name": "Learning_Outcomes"}, |
|
{"name": "Common_Misconceptions"}, |
|
{"name": "Difficulty"}, |
|
{"name": "SourceURL"}, |
|
{"name": "TagsStr"}, |
|
], |
|
templates=[ |
|
{ |
|
"name": "Cloze Card", |
|
"qfmt": """ |
|
<div class=\"card question-side\"> |
|
<div class=\"difficulty-indicator {{Difficulty}}\"></div> |
|
<div class=\"content\"> |
|
<div class=\"question\">{{cloze:Text}}</div> |
|
<div class=\"prerequisites\" onclick=\"event.stopPropagation();\"> |
|
<div class=\"prerequisites-toggle\">Show Prerequisites</div> |
|
<div class=\"prerequisites-content\">{{Prerequisites}}</div> |
|
</div> |
|
</div> |
|
</div> |
|
<script> |
|
document.querySelector('.prerequisites-toggle').addEventListener('click', function(e) { |
|
e.stopPropagation(); |
|
this.parentElement.classList.toggle('show'); |
|
}); |
|
</script> |
|
""", |
|
"afmt": """ |
|
<div class=\"card answer-side\"> |
|
<div class=\"content\"> |
|
<div class=\"question-section\"> |
|
<div class=\"question\">{{cloze:Text}}</div> |
|
<div class=\"prerequisites\"> |
|
<strong>Prerequisites:</strong> {{Prerequisites}} |
|
</div> |
|
</div> |
|
<hr> |
|
|
|
{{#Back Extra}} |
|
<div class=\"back-extra-section\"> |
|
<h3>Additional Information</h3> |
|
<div class=\"back-extra-text\">{{Back Extra}}</div> |
|
</div> |
|
{{/Back Extra}} |
|
|
|
<div class=\"explanation-section\"> |
|
<h3>Explanation</h3> |
|
<div class=\"explanation-text\">{{Explanation}}</div> |
|
</div> |
|
|
|
<div class=\"example-section\"> |
|
<h3>Example</h3> |
|
<div class=\"example-text\">{{Example}}</div> |
|
</div> |
|
|
|
<div class=\"metadata-section\"> |
|
<div class=\"learning-outcomes\"> |
|
<h3>Learning Outcomes</h3> |
|
<div>{{Learning_Outcomes}}</div> |
|
</div> |
|
|
|
<div class=\"misconceptions\"> |
|
<h3>Common Misconceptions - Debunked</h3> |
|
<div>{{Common_Misconceptions}}</div> |
|
</div> |
|
|
|
<div class=\"difficulty\"> |
|
<h3>Difficulty Level</h3> |
|
<div>{{Difficulty}}</div> |
|
</div> |
|
{{#SourceURL}}<div class=\"source-url\"><small>Source: <a href=\"{{SourceURL}}\">{{SourceURL}}</a></small></div>{{/SourceURL}} |
|
</div> |
|
</div> |
|
</div> |
|
""", |
|
} |
|
], |
|
css=""" |
|
/* Base styles */ |
|
.card { |
|
font-family: 'Inter', system-ui, -apple-system, sans-serif; |
|
font-size: 16px; |
|
line-height: 1.6; |
|
color: #1a1a1a; |
|
max-width: 800px; |
|
margin: 0 auto; |
|
padding: 20px; |
|
background: #ffffff; |
|
} |
|
|
|
@media (max-width: 768px) { |
|
.card { |
|
font-size: 14px; |
|
padding: 15px; |
|
} |
|
} |
|
|
|
/* Question side */ |
|
.question-side { |
|
position: relative; |
|
min-height: 200px; |
|
} |
|
|
|
.difficulty-indicator { |
|
position: absolute; |
|
top: 10px; |
|
right: 10px; |
|
width: 10px; |
|
height: 10px; |
|
border-radius: 50%; |
|
} |
|
|
|
.difficulty-indicator.beginner { background: #4ade80; } |
|
.difficulty-indicator.intermediate { background: #fbbf24; } |
|
.difficulty-indicator.advanced { background: #ef4444; } |
|
|
|
.question { |
|
font-size: 1.3em; |
|
font-weight: 600; |
|
color: #2563eb; |
|
margin-bottom: 1.5em; |
|
} |
|
|
|
.prerequisites { |
|
margin-top: 1em; |
|
font-size: 0.9em; |
|
color: #666; |
|
} |
|
|
|
.prerequisites-toggle { |
|
color: #2563eb; |
|
cursor: pointer; |
|
text-decoration: underline; |
|
} |
|
|
|
.prerequisites-content { |
|
display: none; |
|
margin-top: 0.5em; |
|
padding: 0.5em; |
|
background: #f8fafc; |
|
border-radius: 4px; |
|
} |
|
|
|
.prerequisites.show .prerequisites-content { |
|
display: block; |
|
} |
|
|
|
/* Answer side */ |
|
.answer-section, |
|
.explanation-section, |
|
.example-section { |
|
margin: 1.5em 0; |
|
padding: 1.2em; |
|
border-radius: 8px; |
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05); |
|
} |
|
|
|
.answer-section { /* Shared with question for cloze, but can be general */ |
|
background: #f0f9ff; |
|
border-left: 4px solid #2563eb; |
|
} |
|
|
|
.back-extra-section { |
|
background: #eef2ff; /* A slightly different shade for additional info */ |
|
border-left: 4px solid #818cf8; /* Indigo variant */ |
|
margin: 1.5em 0; |
|
padding: 1.2em; |
|
border-radius: 8px; |
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05); |
|
} |
|
|
|
.explanation-section { |
|
background: #f0fdf4; |
|
border-left: 4px solid #4ade80; |
|
} |
|
|
|
.example-section { |
|
background: #fefce8; /* Light yellow */ |
|
border-left: 4px solid #facc15; /* Yellow */ |
|
} |
|
.example-section pre { |
|
background-color: #2d2d2d; /* Darker background for code blocks */ |
|
color: #f8f8f2; /* Light text for contrast */ |
|
padding: 1em; |
|
border-radius: 0.3em; |
|
overflow-x: auto; /* Horizontal scroll for long lines */ |
|
font-family: 'Consolas', 'Monaco', 'Menlo', monospace; |
|
font-size: 0.9em; |
|
line-height: 1.4; |
|
} |
|
|
|
.example-section code { |
|
font-family: 'Consolas', 'Monaco', 'Menlo', monospace; |
|
} |
|
|
|
.metadata-section { |
|
margin-top: 2em; |
|
padding-top: 1em; |
|
border-top: 1px solid #e5e7eb; /* Light gray border */ |
|
font-size: 0.9em; |
|
color: #4b5563; /* Cool gray */ |
|
} |
|
|
|
.metadata-section h3 { |
|
font-size: 1em; |
|
color: #1f2937; /* Darker gray for headings */ |
|
margin-bottom: 0.5em; |
|
} |
|
|
|
.metadata-section > div { |
|
margin-bottom: 0.8em; |
|
} |
|
|
|
.source-url a { |
|
color: #2563eb; |
|
text-decoration: none; |
|
} |
|
.source-url a:hover { |
|
text-decoration: underline; |
|
} |
|
|
|
/* Styles for cloze deletion cards */ |
|
.cloze { |
|
font-weight: bold; |
|
color: blue; |
|
} |
|
.nightMode .cloze { |
|
color: lightblue; |
|
} |
|
|
|
/* General utility */ |
|
hr { |
|
border: none; |
|
border-top: 1px dashed #cbd5e1; /* Light dashed line */ |
|
margin: 1.5em 0; |
|
} |
|
|
|
/* Rich text field styling (if Anki adds classes for these) */ |
|
.field ul, .field ol { |
|
margin-left: 1.5em; |
|
padding-left: 0.5em; |
|
} |
|
.field li { |
|
margin-bottom: 0.3em; |
|
} |
|
""", |
|
|
|
model_type=1, |
|
) |
|
|
|
|
|
|
|
def _get_or_create_model( |
|
model_id: int, |
|
name: str, |
|
fields: List[Dict[str, str]], |
|
templates: List[Dict[str, str]], |
|
) -> genanki.Model: |
|
return genanki.Model(model_id, name, fields=fields, templates=templates) |
|
|
|
|
|
|
|
|
|
|
|
def export_cards_to_csv( |
|
cards: List[Dict[str, Any]], filename: Optional[str] = None |
|
) -> str: |
|
"""Export a list of card dictionaries to a CSV file. |
|
|
|
Args: |
|
cards: A list of dictionaries, where each dictionary represents a card |
|
and should contain 'front' and 'back' keys. Other keys like |
|
'tags' and 'note_type' are optional. |
|
filename: Optional. The desired filename/path for the CSV. |
|
If None, a timestamped filename will be generated. |
|
|
|
Returns: |
|
The path to the generated CSV file. |
|
|
|
Raises: |
|
IOError: If there is an issue writing to the file. |
|
KeyError: If a card dictionary is missing essential keys like 'front' or 'back'. |
|
ValueError: If the cards list is empty or not provided. |
|
""" |
|
if not cards: |
|
logger.warning("export_cards_to_csv called with an empty list of cards.") |
|
raise ValueError("No cards provided to export.") |
|
|
|
if not filename: |
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
|
|
|
|
|
filename = f"ankigen_cards_{timestamp}.csv" |
|
logger.info(f"No filename provided, generated: {filename}") |
|
|
|
|
|
|
|
fieldnames = ["front", "back", "tags", "note_type"] |
|
|
|
try: |
|
logger.info(f"Attempting to export {len(cards)} cards to {filename}") |
|
with open(filename, "w", newline="", encoding="utf-8") as csvfile: |
|
writer = csv.DictWriter( |
|
csvfile, fieldnames=fieldnames, extrasaction="ignore" |
|
) |
|
writer.writeheader() |
|
for i, card in enumerate(cards): |
|
try: |
|
|
|
if "front" not in card or "back" not in card: |
|
raise KeyError( |
|
f"Card at index {i} is missing 'front' or 'back' key." |
|
) |
|
|
|
row_to_write = { |
|
"front": card["front"], |
|
"back": card["back"], |
|
"tags": card.get("tags", ""), |
|
"note_type": card.get("note_type", "Basic"), |
|
} |
|
writer.writerow(row_to_write) |
|
except KeyError as e_inner: |
|
logger.error( |
|
f"Skipping card due to KeyError: {e_inner}. Card data: {card}" |
|
) |
|
|
|
|
|
|
|
continue |
|
logger.info(f"Successfully exported cards to {filename}") |
|
return filename |
|
except IOError as e_io: |
|
logger.error(f"IOError during CSV export to {filename}: {e_io}", exc_info=True) |
|
raise |
|
except Exception as e_general: |
|
logger.error( |
|
f"Unexpected error during CSV export to {filename}: {e_general}", |
|
exc_info=True, |
|
) |
|
raise |
|
|
|
|
|
def export_cards_to_apkg( |
|
cards: List[Dict[str, Any]], |
|
filename: Optional[str] = None, |
|
deck_name: str = "Ankigen Generated Cards", |
|
) -> str: |
|
"""Exports a list of card dictionaries to an Anki .apkg file. |
|
|
|
Args: |
|
cards: List of dictionaries, where each dictionary represents a card. |
|
It's expected that these dicts are prepared by export_dataframe_to_apkg |
|
and contain keys like 'Question', 'Answer', 'Explanation', etc. |
|
filename: The full path (including filename) for the exported file. |
|
If None, a default filename will be generated in the current directory. |
|
deck_name: The name of the deck if exporting to .apkg format. |
|
|
|
Returns: |
|
The path to the exported file. |
|
""" |
|
logger.info(f"Starting APKG export for {len(cards)} cards to deck '{deck_name}'.") |
|
if not filename: |
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
|
filename = f"ankigen_deck_{timestamp}.apkg" |
|
elif not filename.lower().endswith(".apkg"): |
|
filename += ".apkg" |
|
|
|
output_dir = os.path.dirname(filename) |
|
if output_dir and not os.path.exists(output_dir): |
|
os.makedirs(output_dir) |
|
logger.info(f"Created output directory for APKG: {output_dir}") |
|
|
|
anki_basic_model = BASIC_MODEL |
|
anki_cloze_model = CLOZE_MODEL |
|
|
|
deck_id = random.randrange(1 << 30, 1 << 31) |
|
anki_deck = genanki.Deck(deck_id, deck_name) |
|
|
|
notes_added_count = 0 |
|
for card_dict in cards: |
|
note_type = card_dict.get("note_type", "Basic") |
|
tags_for_note_object = card_dict.get("tags_for_note_object", []) |
|
|
|
|
|
question = card_dict.get("Question", "") |
|
answer = card_dict.get("Answer", "") |
|
explanation = card_dict.get("Explanation", "") |
|
example = card_dict.get("Example", "") |
|
prerequisites = card_dict.get("Prerequisites", "") |
|
learning_outcomes = card_dict.get("Learning_Outcomes", "") |
|
common_misconceptions = card_dict.get("Common_Misconceptions", "") |
|
difficulty = card_dict.get("Difficulty", "") |
|
source_url = card_dict.get("SourceURL", "") |
|
tags_str_field = card_dict.get( |
|
"TagsStr", "" |
|
) |
|
|
|
|
|
|
|
if not question: |
|
logger.error( |
|
f"SKIPPING CARD DUE TO EMPTY 'Question' (front/text) field. Card data: {card_dict}" |
|
) |
|
continue |
|
|
|
try: |
|
if note_type.lower() == "cloze": |
|
|
|
|
|
note_fields = [ |
|
question, |
|
answer, |
|
explanation, |
|
example, |
|
prerequisites, |
|
learning_outcomes, |
|
common_misconceptions, |
|
difficulty, |
|
source_url, |
|
tags_str_field, |
|
] |
|
note = genanki.Note( |
|
model=anki_cloze_model, |
|
fields=note_fields, |
|
tags=tags_for_note_object, |
|
) |
|
else: |
|
|
|
|
|
note_fields = [ |
|
question, |
|
answer, |
|
explanation, |
|
example, |
|
prerequisites, |
|
learning_outcomes, |
|
common_misconceptions, |
|
difficulty, |
|
source_url, |
|
tags_str_field, |
|
] |
|
note = genanki.Note( |
|
model=anki_basic_model, |
|
fields=note_fields, |
|
tags=tags_for_note_object, |
|
) |
|
anki_deck.add_note(note) |
|
notes_added_count += 1 |
|
except Exception as e: |
|
logger.error( |
|
f"Failed to create genanki.Note for card: {card_dict}. Error: {e}", |
|
exc_info=True, |
|
) |
|
logger.warning(f"Skipping card due to error: Question='{question[:50]}...'") |
|
|
|
if notes_added_count == 0 and cards: |
|
logger.error( |
|
"No valid notes could be created from the provided cards. APKG generation aborted." |
|
) |
|
|
|
raise gr.Error("Failed to create any valid Anki notes from the input.") |
|
elif not cards: |
|
logger.info("No cards provided to export to APKG. APKG generation skipped.") |
|
|
|
|
|
|
|
raise gr.Error("No cards were provided to generate an APKG file.") |
|
else: |
|
logger.info( |
|
f"Added {notes_added_count} notes to deck '{deck_name}'. Proceeding to package." |
|
) |
|
|
|
|
|
package = genanki.Package(anki_deck) |
|
try: |
|
package.write_to_file(filename) |
|
logger.info(f"Successfully exported Anki deck to {filename}") |
|
except Exception as e: |
|
logger.error(f"Failed to write .apkg file to {filename}: {e}", exc_info=True) |
|
raise IOError(f"Could not write .apkg file: {e}") |
|
|
|
return filename |
|
|
|
|
|
def export_cards_from_crawled_content( |
|
cards: List[Dict[str, Any]], |
|
output_path: Optional[ |
|
str |
|
] = None, |
|
export_format: str = "csv", |
|
deck_name: str = "Ankigen Generated Cards", |
|
) -> str: |
|
"""Exports cards (list of dicts) to the specified format (CSV or APKG). |
|
|
|
Args: |
|
cards: List of dictionaries, where each dictionary represents a card. |
|
Expected keys: 'front', 'back'. Optional: 'tags' (space-separated string), 'source_url', 'note_type' ('Basic' or 'Cloze'). |
|
output_path: The full path (including filename) for the exported file. |
|
If None, a default filename will be generated in the current directory. |
|
export_format: The desired format, either 'csv' or 'apkg'. |
|
deck_name: The name of the deck if exporting to .apkg format. |
|
|
|
Returns: |
|
The path to the exported file. |
|
""" |
|
if not cards: |
|
logger.warning("No cards provided to export_cards_from_crawled_content.") |
|
|
|
raise ValueError("No cards provided to export.") |
|
|
|
logger.info( |
|
f"Exporting {len(cards)} cards to format '{export_format}' with deck name '{deck_name}'." |
|
) |
|
|
|
if export_format.lower() == "csv": |
|
return export_cards_to_csv(cards, filename=output_path) |
|
elif export_format.lower() == "apkg": |
|
return export_cards_to_apkg(cards, filename=output_path, deck_name=deck_name) |
|
else: |
|
supported_formats = ["csv", "apkg"] |
|
logger.error( |
|
f"Unsupported export format: {export_format}. Supported formats: {supported_formats}" |
|
) |
|
|
|
raise ValueError( |
|
f"Unsupported export format: {export_format}. Supported formats: {supported_formats}" |
|
) |
|
|
|
|
|
|
|
def export_dataframe_to_csv( |
|
data: Optional[pd.DataFrame], |
|
filename_suggestion: Optional[str] = "ankigen_cards.csv", |
|
) -> Optional[str]: |
|
"""Exports a Pandas DataFrame to a CSV file, designed for Gradio download. |
|
|
|
Args: |
|
data: The Pandas DataFrame to export. |
|
filename_suggestion: A suggestion for the base filename (e.g., from subject). |
|
|
|
Returns: |
|
The path to the temporary CSV file, or None if an error occurs or data is empty. |
|
""" |
|
logger.info( |
|
f"Attempting to export DataFrame to CSV. Suggested filename: {filename_suggestion}" |
|
) |
|
if data is None or data.empty: |
|
logger.warning( |
|
"No data provided to export_dataframe_to_csv. Skipping CSV export." |
|
) |
|
raise gr.Error( |
|
"No card data available" |
|
) |
|
|
|
|
|
try: |
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
|
base_name_from_suggestion = "ankigen_cards" |
|
|
|
|
|
if filename_suggestion and isinstance(filename_suggestion, str): |
|
|
|
processed_suggestion = filename_suggestion.removesuffix(".csv") |
|
safe_suggestion = ( |
|
processed_suggestion.replace(" ", "_") |
|
.replace("/", "-") |
|
.replace("\\\\", "-") |
|
) |
|
if ( |
|
safe_suggestion |
|
): |
|
base_name_from_suggestion = f"ankigen_{safe_suggestion[:50]}" |
|
|
|
|
|
final_filename = f"{base_name_from_suggestion}_{timestamp}.csv" |
|
|
|
|
|
output_dir = os.path.dirname(final_filename) |
|
if output_dir and not os.path.exists(output_dir): |
|
os.makedirs(output_dir) |
|
logger.info(f"Created output directory for CSV: {output_dir}") |
|
|
|
data.to_csv(final_filename, index=False) |
|
logger.info(f"Successfully exported DataFrame to CSV: {final_filename}") |
|
gr.Info( |
|
f"CSV ready for download: {os.path.basename(final_filename)}" |
|
) |
|
return final_filename |
|
except Exception as e: |
|
logger.error(f"Error exporting DataFrame to CSV: {e}", exc_info=True) |
|
gr.Error(f"Error exporting DataFrame to CSV: {e}") |
|
return None |
|
|
|
|
|
|
|
def export_dataframe_to_apkg( |
|
df: pd.DataFrame, |
|
output_path: Optional[str], |
|
deck_name: str, |
|
) -> str: |
|
"""Exports a DataFrame of cards to an Anki .apkg file.""" |
|
if df.empty: |
|
logger.warning("export_dataframe_to_apkg called with an empty DataFrame.") |
|
raise ValueError("No cards in DataFrame to export.") |
|
|
|
logger.info( |
|
f"Starting APKG export for DataFrame with {len(df)} rows to deck '{deck_name}'. Output: {output_path}" |
|
) |
|
|
|
cards_for_apkg: List[Dict[str, Any]] = [] |
|
for _, row in df.iterrows(): |
|
try: |
|
note_type_val = ( |
|
_format_field_as_string(row.get("Card_Type", "Basic")) or "Basic" |
|
) |
|
topic = _format_field_as_string(row.get("Topic", "")) |
|
difficulty_raw = _format_field_as_string(row.get("Difficulty", "")) |
|
difficulty_plain_for_tag = strip_html_tags( |
|
difficulty_raw |
|
) |
|
|
|
tags_list_for_note_obj = [] |
|
if topic: |
|
tags_list_for_note_obj.append(topic.replace(" ", "_").replace(",", "_")) |
|
if difficulty_plain_for_tag: |
|
|
|
|
|
|
|
safe_difficulty_tag = difficulty_plain_for_tag.replace(" ", "_") |
|
tags_list_for_note_obj.append(safe_difficulty_tag) |
|
|
|
tags_str_for_field = " ".join( |
|
tags_list_for_note_obj |
|
) |
|
|
|
|
|
card_data_for_note = { |
|
"note_type": note_type_val, |
|
"tags_for_note_object": tags_list_for_note_obj, |
|
"TagsStr": tags_str_for_field, |
|
"Question": _format_field_as_string(row.get("Question", "")), |
|
"Answer": _format_field_as_string(row.get("Answer", "")), |
|
"Explanation": _format_field_as_string(row.get("Explanation", "")), |
|
"Example": _format_field_as_string(row.get("Example", "")), |
|
"Prerequisites": _format_field_as_string(row.get("Prerequisites", "")), |
|
"Learning_Outcomes": _format_field_as_string( |
|
row.get("Learning_Outcomes", "") |
|
), |
|
"Common_Misconceptions": _format_field_as_string( |
|
row.get("Common_Misconceptions", "") |
|
), |
|
"Difficulty": difficulty_raw, |
|
"SourceURL": _format_field_as_string(row.get("Source_URL", "")), |
|
} |
|
cards_for_apkg.append(card_data_for_note) |
|
except Exception as e: |
|
logger.error( |
|
f"Error processing DataFrame row for APKG: {row}. Error: {e}", |
|
exc_info=True, |
|
) |
|
continue |
|
|
|
if not cards_for_apkg: |
|
logger.error("No cards could be processed from DataFrame for APKG export.") |
|
raise ValueError("No processable cards found in DataFrame for APKG export.") |
|
|
|
return export_cards_to_apkg( |
|
cards_for_apkg, filename=output_path, deck_name=deck_name |
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
export_csv = ( |
|
export_dataframe_to_csv |
|
) |
|
|
|
|
|
|
|
def export_deck( |
|
df: pd.DataFrame, |
|
output_path: Optional[str] = None, |
|
deck_name: str = "Ankigen Generated Cards", |
|
) -> str: |
|
"""Alias for exporting a DataFrame to APKG, providing a default deck name.""" |
|
if df is None or df.empty: |
|
logger.warning("export_deck called with None or empty DataFrame.") |
|
|
|
raise gr.Error("No card data available") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return export_dataframe_to_apkg(df, output_path=output_path, deck_name=deck_name) |
|
|
|
|
|
export_dataframe_csv = export_dataframe_to_csv |
|
export_dataframe_apkg = export_dataframe_to_apkg |
|
|
|
__all__ = [ |
|
"BASIC_MODEL", |
|
"CLOZE_MODEL", |
|
"export_csv", |
|
"export_deck", |
|
"export_dataframe_csv", |
|
"export_dataframe_apkg", |
|
"export_cards_to_csv", |
|
"export_cards_to_apkg", |
|
"export_cards_from_crawled_content", |
|
"export_dataframe_to_csv", |
|
"export_dataframe_to_apkg", |
|
] |
|
|