Files
AWS-CCP-Notes/practice-exam/generate_anki_deck.py

782 lines
21 KiB
Python

"""
AWS Certified Cloud Practitioner Practice Exam to Anki Deck Converter
This script parses practice exam markdown files and generates Anki decks.
"""
import re
import os
import glob
import random
import genanki
def generate_unique_id():
"""Generate a unique ID for Anki models and decks."""
return random.randrange(1 << 30, 1 << 31)
# Define the Anki card model with interactive quiz functionality
AWS_MODEL = genanki.Model(
generate_unique_id(),
'AWS Practice Exam Interactive Model',
fields=[
{'name': 'Question'},
{'name': 'OptionsHTML'},
{'name': 'Answer'},
{'name': 'Source'},
{'name': 'IsMultiple'}, # "true" or "false" - whether multiple answers are expected
],
templates=[
{
'name': 'Interactive Quiz Card',
'qfmt': '''
<div class="question">{{Question}}</div>
<hr>
<div class="hint-text" id="hintText"></div>
<div class="options-container" id="options">{{OptionsHTML}}</div>
<div class="button-container">
<button id="checkBtn" onclick="checkAnswer()">Antwort prüfen ✓</button>
</div>
<script>
var correctAnswer = "{{Answer}}";
var isMultiple = "{{IsMultiple}}" === "true";
// Toggle option when clicking on the entire box
function toggleOption(event, element) {
var input = element.querySelector('input');
if (input.type === 'checkbox') {
input.checked = !input.checked;
} else {
input.checked = true;
}
}
// Set hint text based on number of answers
(function() {
var hintDiv = document.getElementById('hintText');
if (isMultiple) {
var numAnswers = correctAnswer.split(',').length;
hintDiv.innerHTML = '💡 Wähle ' + numAnswers + ' Antworten aus';
hintDiv.style.display = 'block';
}
})();
function getSelectedAnswers() {
var selected = [];
var inputs = document.querySelectorAll('input[name="answer"]:checked');
inputs.forEach(function(input) {
selected.push(input.value);
});
return selected.sort().join(", ");
}
function checkAnswer() {
var selected = getSelectedAnswers();
if (selected === "") {
alert("Bitte wähle mindestens eine Antwort aus!");
return;
}
// Store the result for the back side
var normalizedCorrect = correctAnswer.replace(/\\s/g, "").toUpperCase().split(",").sort().join(",");
var normalizedSelected = selected.replace(/\\s/g, "").toUpperCase().split(",").sort().join(",");
var isCorrect = normalizedSelected === normalizedCorrect;
// Store result in sessionStorage for back side
sessionStorage.setItem('lastAnswer', selected);
sessionStorage.setItem('wasCorrect', isCorrect ? 'true' : 'false');
// Trigger Anki to show the answer
if (typeof pycmd !== 'undefined') {
pycmd('ans');
} else if (typeof AnkiDroidJS !== 'undefined') {
showAnswer();
}
}
</script>
''',
'afmt': '''
<div class="question">{{Question}}</div>
<hr>
<div class="options-container answered" id="options">{{OptionsHTML}}</div>
<div id="result" class="result"></div>
<div class="correct-answer">
<strong>Richtige Antwort:</strong> {{Answer}}
</div>
<hr>
<div class="source">
<em>Quelle: {{Source}}</em>
</div>
<div class="rating-container">
<p class="rating-hint" id="ratingHint">Bewerte deine Antwort:</p>
<div class="rating-buttons">
<button class="rating-btn again" onclick="answerCard(1)">Nochmal<br><span class="rating-sub">&lt;1min</span></button>
<button class="rating-btn hard" onclick="answerCard(2)">Schwer<br><span class="rating-sub">&lt;6min</span></button>
<button class="rating-btn good" onclick="answerCard(3)">Gut<br><span class="rating-sub">&lt;10min</span></button>
<button class="rating-btn easy" onclick="answerCard(4)">Einfach<br><span class="rating-sub">4 Tage</span></button>
</div>
</div>
<script>
var correctAnswer = "{{Answer}}";
// Cross-platform answer function for desktop Anki and AnkiDroid
function answerCard(ease) {
// AnkiDroid uses direct functions
if (typeof buttonAnswerEase1 !== 'undefined') {
switch(ease) {
case 1: buttonAnswerEase1(); break;
case 2: buttonAnswerEase2(); break;
case 3: buttonAnswerEase3(); break;
case 4: buttonAnswerEase4(); break;
}
} else if (typeof AnkiDroidJS !== 'undefined') {
// Fallback for older AnkiDroid versions
AnkiDroidJS.ankiAnswerCard(ease);
} else if (typeof pycmd !== 'undefined') {
// Desktop Anki
pycmd('ease' + ease);
}
}
(function() {
var selected = sessionStorage.getItem('lastAnswer') || '';
var wasCorrect = sessionStorage.getItem('wasCorrect') === 'true';
var resultDiv = document.getElementById('result');
var ratingHint = document.getElementById('ratingHint');
// Highlight options
var options = document.querySelectorAll('.option-item');
var correctLetters = correctAnswer.replace(/\\s/g, "").toUpperCase().split(",");
var selectedLetters = selected.replace(/\\s/g, "").toUpperCase().split(",");
options.forEach(function(opt) {
var input = opt.querySelector('input');
if (input) {
var letter = input.value.toUpperCase();
input.disabled = true;
if (correctLetters.includes(letter)) {
opt.classList.add('correct-option');
input.checked = true;
}
if (selectedLetters.includes(letter) && !correctLetters.includes(letter)) {
opt.classList.add('incorrect-option');
input.checked = true;
}
}
});
// Show result
if (selected) {
if (wasCorrect) {
resultDiv.innerHTML = "✅ Richtig!";
resultDiv.className = "result correct";
ratingHint.innerHTML = "🎉 Super! Wie gut hast du es gewusst?";
} else {
resultDiv.innerHTML = "❌ Falsch! Deine Antwort: " + selected;
resultDiv.className = "result incorrect";
ratingHint.innerHTML = "📚 Für nächstes Mal merken:";
}
}
// Clear storage
sessionStorage.removeItem('lastAnswer');
sessionStorage.removeItem('wasCorrect');
})();
</script>
''',
},
],
css='''
.card {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
font-size: 16px;
text-align: left;
color: #333;
background-color: #f8f9fa;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.question {
font-weight: 600;
margin-bottom: 20px;
font-size: 18px;
color: #232f3e;
line-height: 1.5;
}
.options-container {
margin: 15px 0;
}
.option-item {
display: flex;
align-items: flex-start;
margin: 10px 0;
padding: 12px 15px;
background-color: #fff;
border-radius: 8px;
border: 2px solid #e0e0e0;
cursor: pointer;
transition: all 0.2s ease;
}
.option-item:hover {
border-color: #ff9900;
background-color: #fff8f0;
}
.options-container.answered .option-item:hover {
border-color: inherit;
background-color: inherit;
}
.option-item input {
margin-right: 12px;
margin-top: 3px;
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #ff9900;
}
.option-item label {
cursor: pointer;
flex: 1;
line-height: 1.4;
}
.option-item.correct-option {
border-color: #28a745;
background-color: #d4edda;
}
.option-item.incorrect-option {
border-color: #dc3545;
background-color: #f8d7da;
}
.hint-text {
display: none;
background-color: #e3f2fd;
color: #1565c0;
padding: 10px 15px;
border-radius: 8px;
margin-bottom: 15px;
font-weight: 500;
}
.button-container {
margin: 20px 0;
text-align: center;
}
button {
padding: 12px 30px;
font-size: 16px;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
margin: 5px;
}
#checkBtn {
background: linear-gradient(135deg, #ff9900, #ffad33);
color: #232f3e;
}
#checkBtn:hover {
background: linear-gradient(135deg, #ec8b00, #ff9900);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 153, 0, 0.4);
}
.result {
padding: 15px 20px;
border-radius: 8px;
margin: 15px 0;
font-size: 18px;
font-weight: 600;
text-align: center;
}
.result.correct {
background-color: #d4edda;
color: #155724;
border: 2px solid #28a745;
}
.result.incorrect {
background-color: #f8d7da;
color: #721c24;
border: 2px solid #dc3545;
}
.correct-answer {
padding: 15px 20px;
background-color: #e8f5e9;
border-radius: 8px;
border-left: 4px solid #28a745;
margin: 15px 0;
font-size: 16px;
}
.source {
color: #666;
font-size: 12px;
margin-top: 10px;
text-align: right;
}
.rating-container {
margin-top: 20px;
padding: 20px;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.rating-hint {
text-align: center;
font-size: 16px;
margin-bottom: 15px;
color: #333;
}
.rating-buttons {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
.rating-btn {
padding: 15px 20px;
min-width: 80px;
font-size: 14px;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
line-height: 1.3;
}
.rating-btn .rating-sub {
font-size: 11px;
opacity: 0.8;
}
.rating-btn.again {
background: #dc3545;
color: white;
}
.rating-btn.again:hover {
background: #c82333;
transform: translateY(-2px);
}
.rating-btn.hard {
background: #fd7e14;
color: white;
}
.rating-btn.hard:hover {
background: #e96b02;
transform: translateY(-2px);
}
.rating-btn.good {
background: #28a745;
color: white;
}
.rating-btn.good:hover {
background: #218838;
transform: translateY(-2px);
}
.rating-btn.easy {
background: #17a2b8;
color: white;
}
.rating-btn.easy:hover {
background: #138496;
transform: translateY(-2px);
}
hr {
border: none;
border-top: 1px solid #dee2e6;
margin: 20px 0;
}
/* Hide custom rating buttons on AnkiDroid/mobile (uses built-in buttons) */
.mobile .rating-container,
.android .rating-container {
display: none !important;
}
/* Dark Mode Styles */
.night_mode .card {
background-color: #1e1e2e;
color: #cdd6f4;
}
.night_mode .question {
color: #cdd6f4;
}
.night_mode .option-item {
background-color: #313244;
border-color: #45475a;
color: #cdd6f4;
}
.night_mode .option-item:hover {
border-color: #fab387;
background-color: #45475a;
}
.night_mode .option-item label {
color: #cdd6f4;
}
.night_mode .option-item.correct-option {
border-color: #a6e3a1;
background-color: rgba(166, 227, 161, 0.2);
}
.night_mode .option-item.incorrect-option {
border-color: #f38ba8;
background-color: rgba(243, 139, 168, 0.2);
}
.night_mode .hint-text {
background-color: rgba(137, 180, 250, 0.2);
color: #89b4fa;
}
.night_mode #checkBtn {
background: linear-gradient(135deg, #fab387, #f9e2af);
color: #1e1e2e;
}
.night_mode #checkBtn:hover {
background: linear-gradient(135deg, #f9e2af, #fab387);
box-shadow: 0 4px 12px rgba(250, 179, 135, 0.4);
}
.night_mode .result.correct {
background-color: rgba(166, 227, 161, 0.2);
color: #a6e3a1;
border-color: #a6e3a1;
}
.night_mode .result.incorrect {
background-color: rgba(243, 139, 168, 0.2);
color: #f38ba8;
border-color: #f38ba8;
}
.night_mode .correct-answer {
background-color: rgba(166, 227, 161, 0.15);
border-left-color: #a6e3a1;
color: #cdd6f4;
}
.night_mode .source {
color: #a6adc8;
}
.night_mode .rating-container {
background-color: #313244;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.night_mode .rating-hint {
color: #cdd6f4;
}
.night_mode hr {
border-top-color: #45475a;
}
'''
)
def parse_markdown_file(filepath: str) -> list[dict]:
"""
Parse a practice exam markdown file and extract questions.
Args:
filepath: Path to the markdown file
Returns:
List of dictionaries containing question data
"""
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
questions = []
# Pattern to match each question block
# Questions start with a number followed by a period
question_pattern = r'(\d+)\.\s+(.+?)(?=\n\s*-\s+A\.)'
# Split content by question numbers at the start of lines
question_blocks = re.split(r'\n(?=\d+\.\s)', content)
for block in question_blocks:
if not block.strip():
continue
# Match question number and text
q_match = re.match(r'(\d+)\.\s+(.+?)(?=\n\s*-\s+A\.)', block, re.DOTALL)
if not q_match:
continue
question_num = q_match.group(1)
question_text = q_match.group(2).strip()
# Extract options (A, B, C, D, E)
options = []
option_pattern = r'-\s+([A-E])\.(.+?)(?=(?:\n\s*-\s+[A-E]\.)|\n\s*<details|\Z)'
option_matches = re.findall(option_pattern, block, re.DOTALL)
for letter, text in option_matches:
options.append(f"{letter}. {text.strip()}")
# Extract answer from <details> block
answer_pattern = r'Correct answer:\s*([A-E](?:,\s*[A-E])*)'
answer_match = re.search(answer_pattern, block)
if answer_match and options:
answer = answer_match.group(1).strip()
# Check if multiple answers are expected
is_multiple = ',' in answer
questions.append({
'number': question_num,
'question': question_text,
'options': options,
'answer': answer,
'is_multiple': is_multiple
})
return questions
def create_anki_deck(questions: list[dict], deck_name: str, source_file: str) -> genanki.Deck:
"""
Create an Anki deck from parsed questions.
Args:
questions: List of question dictionaries
deck_name: Name for the Anki deck
source_file: Source file name for reference
Returns:
genanki.Deck object
"""
deck = genanki.Deck(generate_unique_id(), deck_name)
for q in questions:
# Format options as interactive HTML with checkboxes or radio buttons
is_multiple = q.get('is_multiple', False)
input_type = 'checkbox' if is_multiple else 'radio'
options_html_parts = []
for opt in q['options']:
letter = opt[0] # First character is the option letter
text = opt[3:] # Skip "X. " prefix
option_html = f'''<div class="option-item" onclick="toggleOption(event, this)">
<input type="{input_type}" name="answer" value="{letter}" id="opt_{letter}" onclick="event.stopPropagation()">
<label for="opt_{letter}"><strong>{letter}.</strong> {text}</label>
</div>'''
options_html_parts.append(option_html)
options_html = '\n'.join(options_html_parts)
note = genanki.Note(
model=AWS_MODEL,
fields=[
q['question'],
options_html,
q['answer'],
f"{source_file} - Q{q['number']}",
"true" if is_multiple else "false"
]
)
deck.add_note(note)
return deck
def process_single_file(filepath: str, output_dir: str = None):
"""
Process a single practice exam file and create an Anki deck.
Args:
filepath: Path to the markdown file
output_dir: Output directory for the .apkg file (defaults to same as input)
"""
if output_dir is None:
output_dir = os.path.dirname(filepath)
filename = os.path.basename(filepath)
deck_name = f"AWS CCP - {filename.replace('.md', '').replace('-', ' ').title()}"
print(f"Processing: {filename}")
questions = parse_markdown_file(filepath)
print(f" Found {len(questions)} questions")
if questions:
deck = create_anki_deck(questions, deck_name, filename)
output_path = os.path.join(output_dir, filename.replace('.md', '.apkg'))
genanki.Package(deck).write_to_file(output_path)
print(f" Created: {output_path}")
return deck
else:
print(f" No questions found in {filename}")
return None
def process_all_files(directory: str, output_dir: str = None, combined: bool = True):
"""
Process all practice exam files in a directory.
Args:
directory: Directory containing practice exam markdown files
output_dir: Output directory for .apkg files
combined: If True, create a single combined deck; if False, create separate decks
"""
if output_dir is None:
output_dir = directory
pattern = os.path.join(directory, 'practice-exam-*.md')
files = sorted(glob.glob(pattern))
if not files:
print(f"No practice exam files found matching: {pattern}")
return
print(f"Found {len(files)} practice exam files")
if combined:
# Create a combined deck
all_questions = []
for filepath in files:
filename = os.path.basename(filepath)
print(f"Processing: {filename}")
questions = parse_markdown_file(filepath)
# Add source file info to each question
for q in questions:
q['source_file'] = filename
all_questions.extend(questions)
print(f" Found {len(questions)} questions")
print(f"\nTotal questions: {len(all_questions)}")
if all_questions:
deck = genanki.Deck(
generate_unique_id(),
'AWS Certified Cloud Practitioner - All Practice Exams'
)
for q in all_questions:
# Format options as interactive HTML with checkboxes or radio buttons
is_multiple = q.get('is_multiple', False)
input_type = 'checkbox' if is_multiple else 'radio'
options_html_parts = []
for opt in q['options']:
letter = opt[0] # First character is the option letter
text = opt[3:] # Skip "X. " prefix
option_html = f'''<div class="option-item" onclick="toggleOption(event, this)">
<input type="{input_type}" name="answer" value="{letter}" id="opt_{letter}" onclick="event.stopPropagation()">
<label for="opt_{letter}"><strong>{letter}.</strong> {text}</label>
</div>'''
options_html_parts.append(option_html)
options_html = '\n'.join(options_html_parts)
note = genanki.Note(
model=AWS_MODEL,
fields=[
q['question'],
options_html,
q['answer'],
f"{q['source_file']} - Q{q['number']}",
"true" if is_multiple else "false"
]
)
deck.add_note(note)
output_path = os.path.join(output_dir, 'aws-ccp-all-practice-exams.apkg')
genanki.Package(deck).write_to_file(output_path)
print(f"\nCreated combined deck: {output_path}")
else:
# Create separate decks for each file
for filepath in files:
process_single_file(filepath, output_dir)
def main():
"""Main entry point."""
import argparse
parser = argparse.ArgumentParser(
description='Convert AWS Practice Exam markdown files to Anki decks'
)
parser.add_argument(
'input',
nargs='?',
default='.',
help='Input file or directory (default: current directory)'
)
parser.add_argument(
'-o', '--output',
help='Output directory for .apkg files (default: same as input)'
)
parser.add_argument(
'-s', '--separate',
action='store_true',
help='Create separate decks for each file instead of a combined deck'
)
parser.add_argument(
'-f', '--file',
help='Process a single specific file'
)
args = parser.parse_args()
if args.file:
# Process single file
process_single_file(args.file, args.output)
elif os.path.isfile(args.input):
# Input is a file
process_single_file(args.input, args.output)
else:
# Input is a directory
process_all_files(args.input, args.output, combined=not args.separate)
if __name__ == '__main__':
main()