465 lines
19 KiB
Python
465 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Flashcard Learning System - Flask API + Web UI
|
|
Features:
|
|
- Create/manage decks and cards (EN/KO)
|
|
- Spaced repetition scheduling
|
|
- Study sessions with tracking
|
|
- User separation (for future SSO)
|
|
"""
|
|
|
|
from flask import Flask, request, jsonify, render_template_string, session
|
|
import os
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
|
|
app = Flask(__name__)
|
|
app.secret_key = os.environ.get('FLASH_CARD_SECRET', 'dev-secret-change-in-prod')
|
|
|
|
DATA_DIR = os.environ.get('FLASH_CARD_DATA_DIR', '/home/joungmin/flashcards')
|
|
|
|
def load_data():
|
|
os.makedirs(DATA_DIR, exist_ok=True)
|
|
users_file = os.path.join(DATA_DIR, 'users.json')
|
|
decks_file = os.path.join(DATA_DIR, 'decks.json')
|
|
cards_file = os.path.join(DATA_DIR, 'cards.json')
|
|
sessions_file = os.path.join(DATA_DIR, 'sessions.json')
|
|
|
|
def read_json(f, default):
|
|
if os.path.exists(f):
|
|
with open(f, 'r') as file:
|
|
return json.load(file)
|
|
return default
|
|
|
|
return {
|
|
'users': read_json(users_file, {}),
|
|
'decks': read_json(decks_file, {}),
|
|
'cards': read_json(cards_file, {}),
|
|
'sessions': read_json(sessions_file, {})
|
|
}
|
|
|
|
def save_data(data):
|
|
users_file = os.path.join(DATA_DIR, 'users.json')
|
|
decks_file = os.path.join(DATA_DIR, 'decks.json')
|
|
cards_file = os.path.join(DATA_DIR, 'cards.json')
|
|
sessions_file = os.path.join(DATA_DIR, 'sessions.json')
|
|
|
|
with open(users_file, 'w') as f:
|
|
json.dump(data['users'], f, indent=2, default=str)
|
|
with open(decks_file, 'w') as f:
|
|
json.dump(data['decks'], f, indent=2, default=str)
|
|
with open(cards_file, 'w') as f:
|
|
json.dump(data['cards'], f, indent=2, default=str)
|
|
with open(sessions_file, 'w') as f:
|
|
json.dump(data['sessions'], f, indent=2, default=str)
|
|
|
|
def get_next_id(data, key):
|
|
if not data[key]:
|
|
return 1
|
|
return max(int(k) for k in data[key].keys()) + 1
|
|
|
|
def get_current_user():
|
|
return session.get('user_id', 1)
|
|
|
|
HTML_DECKS = """
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Flashcards - EN/KO Learning</title>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pico/css/pico.min.css">
|
|
<style>
|
|
article { margin: 0.5rem 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="container">
|
|
<h1>My Decks</h1>
|
|
<a href="/deck/new" class="btn btn-primary">+ New Deck</a>
|
|
{% if decks %}
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 1rem; margin-top: 1rem;">
|
|
{% for deck_id, deck in decks.items() %}
|
|
<article>
|
|
<header><strong>{{ deck.name }}</strong></header>
|
|
<p>{{ deck.description or 'No description' }}</p>
|
|
<p><small>{{ deck.language_pair }} • {{ deck.card_count }} cards</small></p>
|
|
<footer>
|
|
<a href="/deck/{{ deck_id }}" class="btn btn-secondary">Edit</a>
|
|
<a href="/study/{{ deck_id }}" class="btn btn-primary">Study</a>
|
|
</footer>
|
|
</article>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
<p>No decks yet. Create your first deck!</p>
|
|
{% endif %}
|
|
</main>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
HTML_DECK_EDIT = """
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{% if deck_id %}Edit{% else %}New{% endif %} Deck</title>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pico/css/pico.min.css">
|
|
</head>
|
|
<body>
|
|
<main class="container">
|
|
<h1>{% if deck_id %}Edit Deck{% else %}New Deck{% endif %}</h1>
|
|
<form method="POST">
|
|
<label>Name <input type="text" name="name" value="{{ deck.name if deck else '' }}" required></label>
|
|
<label>Description <textarea name="description">{{ deck.description if deck else '' }}</textarea></label>
|
|
<label>Language Pair
|
|
<select name="language_pair">
|
|
<option value="EN-KO" {% if deck and deck.language_pair == 'EN-KO' %}selected{% endif %}>English - Korean</option>
|
|
<option value="KO-EN" {% if deck and deck.language_pair == 'KO-EN' %}selected{% endif %}>Korean - English</option>
|
|
<option value="MULTI" {% if deck and deck.language_pair == 'MULTI' %}selected{% endif %}>Multi-language</option>
|
|
</select>
|
|
</label>
|
|
<button type="submit" class="btn btn-primary">Save</button>
|
|
</form>
|
|
{% if deck_id %}
|
|
<hr><h2>Cards ({{ cards|length }})</h2>
|
|
<a href="/card/new/{{ deck_id }}" class="btn btn-primary">+ Add Card</a>
|
|
{% for card_id, card in cards.items() %}
|
|
<div style="background:#f8f9fa;padding:1rem;margin:0.5rem 0;border-radius:8px;">
|
|
<strong>{{ card.question_en }}</strong><br>
|
|
<small>{{ card.answer_en or card.question_ko }}</small><br>
|
|
<small><a href="/card/{{ card_id }}/edit">Edit</a> | <a href="/card/{{ card_id }}/delete" onclick="return confirm('Delete?')">Delete</a></small>
|
|
</div>
|
|
{% endfor %}
|
|
{% endif %}
|
|
</main>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
HTML_CARD_EDIT = """
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{% if card_id %}Edit{% else %}New{% endif %} Card</title>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pico/css/pico.min.css">
|
|
</head>
|
|
<body>
|
|
<main class="container">
|
|
<h1>{% if card_id %}Edit Card{% else %}New Card{% endif %}</h1>
|
|
<form method="POST">
|
|
<fieldset><legend>English</legend>
|
|
<label>Question (required) <input type="text" name="question_en" value="{{ card.question_en if card else '' }}" required></label>
|
|
<label>Answer <input type="text" name="answer_en" value="{{ card.answer_en if card else '' }}"></label>
|
|
</fieldset>
|
|
<fieldset><legend>Korean</legend>
|
|
<label>Question (required) <input type="text" name="question_ko" value="{{ card.question_ko if card else '' }}" required></label>
|
|
<label>Answer <input type="text" name="answer_ko" value="{{ card.answer_ko if card else '' }}"></label>
|
|
</fieldset>
|
|
<label>Example Sentence <textarea name="example_sentence">{{ card.example_sentence if card else '' }}</textarea></label>
|
|
<label>Difficulty (1-5) <input type="number" name="difficulty_level" min="1" max="5" value="{{ card.difficulty_level if card else 1 }}"></label>
|
|
<button type="submit" class="btn btn-primary">Save</button>
|
|
</form>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
HTML_STUDY = """
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Study: {{ deck.name }}</title>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pico/css/pico.min.css">
|
|
<style>
|
|
.study-card { background: white; border: 2px solid #0d6efd; border-radius: 12px; padding: 2rem; margin: 1rem 0; text-align: center; }
|
|
.stats { display: flex; gap: 1rem; justify-content: center; margin: 1rem 0; }
|
|
.stat { background: #e9ecef; padding: 0.5rem 1rem; border-radius: 8px; }
|
|
.hidden { display: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="container">
|
|
<h1>Study: {{ deck.name }}</h1>
|
|
<div class="stats">
|
|
<div class="stat">Total: {{ cards|length }}</div>
|
|
<div class="stat">Due: {{ due_cards|length }}</div>
|
|
<div class="stat">Correct: {{ correct }}</div>
|
|
<div class="stat">Incorrect: {{ incorrect }}</div>
|
|
</div>
|
|
{% if due_cards %}
|
|
<div class="study-card" id="card-display">
|
|
<h3>Question</h3>
|
|
<p style="font-size: 1.5rem;">{{ current_card.question_en }}</p>
|
|
<p><small>{{ deck.language_pair }}</small></p>
|
|
</div>
|
|
<div id="answer-section" class="hidden">
|
|
<div class="study-card" style="border-color: #198754;">
|
|
<h3>Answer</h3>
|
|
<p style="font-size: 1.5rem;">{{ current_card.answer_en or current_card.question_ko }}</p>
|
|
</div>
|
|
<div style="display: flex; gap: 1rem; justify-content: center;">
|
|
<button onclick="record_result(false)" class="btn btn-danger">Incorrect</button>
|
|
<button onclick="record_result(true)" class="btn btn-success">Correct</button>
|
|
</div>
|
|
</div>
|
|
<button id="show-answer-btn" onclick="showAnswer()" class="btn btn-primary" style="width: 100%;">Show Answer</button>
|
|
<script>
|
|
let currentCardId = {{ current_card.card_id }};
|
|
let dueCards = {{ due_cards|tojson }};
|
|
let cardIndex = 0;
|
|
let correct = {{ correct }};
|
|
let incorrect = {{ incorrect }};
|
|
function showAnswer() {
|
|
document.getElementById('answer-section').classList.remove('hidden');
|
|
document.getElementById('show-answer-btn').classList.add('hidden');
|
|
}
|
|
function record_result(wasCorrect) {
|
|
fetch('/api/session/{{ session_id }}/result', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({card_id: currentCardId, was_correct: wasCorrect})
|
|
});
|
|
if (wasCorrect) correct++; else incorrect++;
|
|
cardIndex++;
|
|
if (cardIndex < dueCards.length) {
|
|
loadCard(dueCards[cardIndex]);
|
|
} else {
|
|
alert('Session complete! Correct: ' + correct + ', Incorrect: ' + incorrect);
|
|
window.location.href = '/';
|
|
}
|
|
}
|
|
function loadCard(card) {
|
|
currentCardId = card.card_id;
|
|
document.getElementById('card-display').innerHTML = '<h3>Question</h3><p style="font-size: 1.5rem;">' + card.question_en + '</p><p><small>{{ deck.language_pair }}</small></p>';
|
|
document.getElementById('answer-section').classList.add('hidden');
|
|
document.getElementById('show-answer-btn').classList.remove('hidden');
|
|
}
|
|
</script>
|
|
{% else %}
|
|
<p>No cards due for review!</p>
|
|
<a href="/" class="btn btn-primary">Back to Decks</a>
|
|
{% endif %}
|
|
</main>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
@app.route('/')
|
|
def index():
|
|
data = load_data()
|
|
user_id = get_current_user()
|
|
user_decks = {k: v for k, v in data['decks'].items()
|
|
if str(v.get('user_id', 1)) == str(user_id)}
|
|
for deck_id in user_decks:
|
|
cards = [c for c in data['cards'].values()
|
|
if str(c.get('deck_id')) == str(deck_id)]
|
|
user_decks[deck_id]['card_count'] = len(cards)
|
|
return render_template_string(HTML_DECKS, decks=user_decks)
|
|
|
|
@app.route('/deck/new', methods=['GET', 'POST'])
|
|
def new_deck():
|
|
data = load_data()
|
|
if request.method == 'POST':
|
|
deck_id = get_next_id(data, 'decks')
|
|
user_id = get_current_user()
|
|
data['decks'][str(deck_id)] = {
|
|
'deck_id': deck_id, 'user_id': user_id,
|
|
'name': request.form['name'],
|
|
'description': request.form.get('description', ''),
|
|
'language_pair': request.form.get('language_pair', 'EN-KO'),
|
|
'created_at': datetime.now().isoformat(), 'is_active': 'Y'
|
|
}
|
|
save_data(data)
|
|
return redirect(f'/deck/{deck_id}')
|
|
return render_template_string(HTML_DECK_EDIT, deck=None, cards={}, deck_id=None)
|
|
|
|
@app.route('/deck/<deck_id>')
|
|
def view_deck(deck_id):
|
|
data = load_data()
|
|
deck = data['decks'].get(str(deck_id))
|
|
if not deck:
|
|
return "Deck not found", 404
|
|
cards = {k: v for k, v in data['cards'].items()
|
|
if str(v.get('deck_id')) == str(deck_id)}
|
|
return render_template_string(HTML_DECK_EDIT, deck=deck, cards=cards, deck_id=deck_id)
|
|
|
|
@app.route('/deck/<deck_id>/edit', methods=['POST'])
|
|
def edit_deck(deck_id):
|
|
data = load_data()
|
|
if str(deck_id) in data['decks']:
|
|
data['decks'][str(deck_id)].update({
|
|
'name': request.form['name'],
|
|
'description': request.form.get('description', ''),
|
|
'language_pair': request.form.get('language_pair', 'EN-KO'),
|
|
'updated_at': datetime.now().isoformat()
|
|
})
|
|
save_data(data)
|
|
return redirect(f'/deck/{deck_id}')
|
|
|
|
@app.route('/card/new/<deck_id>', methods=['GET', 'POST'])
|
|
def new_card(deck_id):
|
|
data = load_data()
|
|
if request.method == 'POST':
|
|
card_id = get_next_id(data, 'cards')
|
|
data['cards'][str(card_id)] = {
|
|
'card_id': card_id, 'deck_id': int(deck_id),
|
|
'question_en': request.form['question_en'],
|
|
'answer_en': request.form.get('answer_en', ''),
|
|
'question_ko': request.form['question_ko'],
|
|
'answer_ko': request.form.get('answer_ko', ''),
|
|
'example_sentence': request.form.get('example_sentence', ''),
|
|
'difficulty_level': int(request.form.get('difficulty_level', 1)),
|
|
'created_at': datetime.now().isoformat(), 'is_active': 'Y'
|
|
}
|
|
save_data(data)
|
|
return redirect(f'/deck/{deck_id}')
|
|
return render_template_string(HTML_CARD_EDIT, card=None, card_id=None)
|
|
|
|
@app.route('/card/<card_id>/edit', methods=['GET', 'POST'])
|
|
def edit_card(card_id):
|
|
data = load_data()
|
|
card = data['cards'].get(str(card_id))
|
|
if not card:
|
|
return "Card not found", 404
|
|
if request.method == 'POST':
|
|
card.update({
|
|
'question_en': request.form['question_en'],
|
|
'answer_en': request.form.get('answer_en', ''),
|
|
'question_ko': request.form['question_ko'],
|
|
'answer_ko': request.form.get('answer_ko', ''),
|
|
'example_sentence': request.form.get('example_sentence', ''),
|
|
'difficulty_level': int(request.form.get('difficulty_level', 1)),
|
|
'updated_at': datetime.now().isoformat()
|
|
})
|
|
save_data(data)
|
|
return redirect(f"/deck/{card['deck_id']}")
|
|
return render_template_string(HTML_CARD_EDIT, card=card, card_id=card_id)
|
|
|
|
@app.route('/card/<card_id>/delete')
|
|
def delete_card(card_id):
|
|
data = load_data()
|
|
if str(card_id) in data['cards']:
|
|
deck_id = data['cards'][str(card_id)]['deck_id']
|
|
del data['cards'][str(card_id)]
|
|
save_data(data)
|
|
return redirect(f'/deck/{deck_id}')
|
|
return "Card not found", 404
|
|
|
|
@app.route('/study/<deck_id>')
|
|
def study_deck(deck_id):
|
|
data = load_data()
|
|
deck = data['decks'].get(str(deck_id))
|
|
if not deck:
|
|
return "Deck not found", 404
|
|
now = datetime.now().isoformat()
|
|
cards = [c for c in data['cards'].values()
|
|
if str(c.get('deck_id')) == str(deck_id) and c.get('is_active', 'Y') == 'Y']
|
|
cards.sort(key=lambda x: (x.get('next_review_at', now), x.get('difficulty_level', 1)))
|
|
session_id = get_next_id(data, 'sessions')
|
|
data['sessions'][str(session_id)] = {
|
|
'session_id': session_id, 'user_id': get_current_user(),
|
|
'deck_id': int(deck_id), 'started_at': now,
|
|
'cards_reviewed': 0, 'cards_correct': 0, 'cards_incorrect': 0
|
|
}
|
|
save_data(data)
|
|
return render_template_string(
|
|
HTML_STUDY, deck=deck, cards=cards,
|
|
due_cards=cards[:20], current_card=cards[0] if cards else None,
|
|
session_id=session_id, correct=0, incorrect=0
|
|
)
|
|
|
|
# API Routes
|
|
@app.route('/api/decks', methods=['GET'])
|
|
def api_decks():
|
|
data = load_data()
|
|
user_id = get_current_user()
|
|
user_decks = [v for k, v in data['decks'].items()
|
|
if str(v.get('user_id', 1)) == str(user_id)]
|
|
return jsonify({'decks': user_decks})
|
|
|
|
@app.route('/api/decks', methods=['POST'])
|
|
def api_create_deck():
|
|
data = load_data()
|
|
deck_id = get_next_id(data, 'decks')
|
|
user_id = get_current_user()
|
|
req = request.json
|
|
data['decks'][str(deck_id)] = {
|
|
'deck_id': deck_id, 'user_id': user_id,
|
|
'name': req.get('name'), 'description': req.get('description', ''),
|
|
'language_pair': req.get('language_pair', 'EN-KO'),
|
|
'created_at': datetime.now().isoformat(), 'is_active': 'Y'
|
|
}
|
|
save_data(data)
|
|
return jsonify({'success': True, 'deck_id': deck_id})
|
|
|
|
@app.route('/api/cards', methods=['GET'])
|
|
def api_cards():
|
|
data = load_data()
|
|
deck_id = request.args.get('deck_id')
|
|
cards = data['cards'].values()
|
|
if deck_id:
|
|
cards = [c for c in cards if str(c.get('deck_id')) == str(deck_id)]
|
|
return jsonify({'cards': list(cards)})
|
|
|
|
@app.route('/api/cards', methods=['POST'])
|
|
def api_create_card():
|
|
data = load_data()
|
|
card_id = get_next_id(data, 'cards')
|
|
req = request.json
|
|
data['cards'][str(card_id)] = {
|
|
'card_id': card_id, 'deck_id': req.get('deck_id'),
|
|
'question_en': req.get('question_en'),
|
|
'answer_en': req.get('answer_en', ''),
|
|
'question_ko': req.get('question_ko'),
|
|
'answer_ko': req.get('answer_ko', ''),
|
|
'example_sentence': req.get('example_sentence', ''),
|
|
'difficulty_level': req.get('difficulty_level', 1),
|
|
'created_at': datetime.now().isoformat(), 'is_active': 'Y'
|
|
}
|
|
save_data(data)
|
|
return jsonify({'success': True, 'card_id': card_id})
|
|
|
|
@app.route('/api/cards/<card_id>', methods=['DELETE'])
|
|
def api_delete_card(card_id):
|
|
data = load_data()
|
|
if str(card_id) in data['cards']:
|
|
del data['cards'][str(card_id)]
|
|
save_data(data)
|
|
return jsonify({'success': True})
|
|
return jsonify({'error': 'Card not found'}), 404
|
|
|
|
@app.route('/api/session/<session_id>/result', methods=['POST'])
|
|
def api_session_result(session_id):
|
|
data = load_data()
|
|
if str(session_id) in data['sessions']:
|
|
req = request.json
|
|
session_data = data['sessions'][str(session_id)]
|
|
session_data['cards_reviewed'] = session_data.get('cards_reviewed', 0) + 1
|
|
if req.get('was_correct'):
|
|
session_data['cards_correct'] = session_data.get('cards_correct', 0) + 1
|
|
else:
|
|
session_data['cards_incorrect'] = session_data.get('cards_incorrect', 0) + 1
|
|
card_id = req.get('card_id')
|
|
if str(card_id) in data['cards']:
|
|
card = data['cards'][str(card_id)]
|
|
card['times_reviewed'] = card.get('times_reviewed', 0) + 1
|
|
if req.get('was_correct'):
|
|
card['times_correct'] = card.get('times_correct', 0) + 1
|
|
card['last_reviewed_at'] = datetime.now().isoformat()
|
|
days = card.get('times_correct', 1) if req.get('was_correct') else 0
|
|
card['next_review_at'] = (datetime.now() + timedelta(days=days*2)).isoformat()
|
|
save_data(data)
|
|
return jsonify({'success': True})
|
|
return jsonify({'error': 'Session not found'}), 404
|
|
|
|
if __name__ == '__main__':
|
|
port = int(os.environ.get('PORT', 8082))
|
|
app.run(host='0.0.0.0', port=port, debug=False)
|