feat(trainer): add GitHub activity heatmap, 4-option quiz mode, active typing, and duplicate checks

This commit is contained in:
2026-09-23 10:02:29 +00:00
parent d31c77fdd0
commit 2294121777
16 changed files with 4318 additions and 685 deletions
+151
View File
@@ -0,0 +1,151 @@
"""
Export module for notes.qpid.pro — Stage 2
Supports: CSV (Excel-friendly with BOM), JSON, Anki TSV (Cloze-ready), Markdown.
"""
import csv
import io
import json
import re
from datetime import datetime, timezone
from typing import List, Dict, Any
def export_csv(cards: List[Dict[str, Any]]) -> bytes:
"""CSV with UTF-8 BOM for correct Excel auto-detection of encoding."""
output = io.StringIO()
fieldnames = [
"term", "translation", "context", "phonetic",
"part_of_speech", "definition_en", "collection", "tags", "notes", "created_at",
]
writer = csv.DictWriter(
output,
fieldnames=fieldnames,
extrasaction="ignore",
quoting=csv.QUOTE_MINIMAL,
lineterminator="\r\n",
)
writer.writeheader()
for c in cards:
writer.writerow({
"term": c.get("term") or "",
"translation": c.get("translation") or "",
"context": c.get("context_sentence") or c.get("context") or "",
"phonetic": c.get("phonetic") or "",
"part_of_speech": c.get("part_of_speech") or "",
"definition_en": c.get("definition_en") or "",
"collection": c.get("collection") or "General",
"tags": c.get("tags") or "",
"notes": c.get("notes") or "",
"created_at": c.get("created_at") or "",
})
# UTF-8 BOM (\ufeff) so Excel auto-detects encoding
return ("\ufeff" + output.getvalue()).encode("utf-8")
def export_json(cards: List[Dict[str, Any]]) -> bytes:
"""Full JSON export with metadata header."""
payload = {
"exported_at": datetime.now(timezone.utc).isoformat(),
"total": len(cards),
"cards": [
{
"id": c.get("id"),
"term": c.get("term") or "",
"translation": c.get("translation") or "",
"context_sentence": c.get("context_sentence") or c.get("context") or "",
"phonetic": c.get("phonetic") or "",
"part_of_speech": c.get("part_of_speech") or "",
"definition_en": c.get("definition_en") or "",
"collection": c.get("collection") or "General",
"tags": c.get("tags") or "",
"notes": c.get("notes") or "",
"audio_url": c.get("audio_url") or "",
"created_at": c.get("created_at") or "",
"updated_at": c.get("updated_at") or "",
}
for c in cards
],
}
return json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
def export_anki_tsv(cards: List[Dict[str, Any]]) -> bytes:
"""
Anki-compatible tab-separated export with cloze deletion support.
"""
lines: List[str] = [
"#separator:tab",
"#html:true",
"#notetype:Basic (and reversed card)",
"#columns:Front\tBack\tTags\tContext",
]
for c in cards:
term = (c.get("term") or "").replace("\t", " ").replace("\n", " ")
translation = (c.get("translation") or "").replace("\t", " ").replace("\n", " ")
phonetic = (c.get("phonetic") or "").replace("\t", " ")
context = (c.get("context_sentence") or c.get("context") or "").replace("\t", " ").replace("\n", " ")
tags_raw = c.get("tags") or ""
collection = (c.get("collection") or "General").strip().replace(" ", "_")
# Cloze representation if context contains the term
cloze_ctx = ""
if context and term and term.lower() in context.lower():
cloze_ctx = re.sub(re.escape(term), f"{{{{c1::{term}}}}}", context, flags=re.IGNORECASE)
tag_parts = [f"collection_{collection}"] + [
t.strip().replace(" ", "_")
for t in tags_raw.split(",") if t.strip()
]
tags_str = " ".join(tag_parts)
front = term if not phonetic else f"{term} [{phonetic}]"
back = translation
lines.append(f"{front}\t{back}\t{tags_str}\t{cloze_ctx or context}")
return ("\n".join(lines) + "\n").encode("utf-8")
def export_markdown(cards: List[Dict[str, Any]]) -> bytes:
"""Human-readable Markdown export, grouped by collection."""
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
lines: List[str] = [
f"# 📚 Vocabulary Export — {now_str}",
f"**Total cards:** {len(cards)}",
"",
]
by_collection: Dict[str, List[Dict[str, Any]]] = {}
for c in cards:
col = (c.get("collection") or "General").strip()
by_collection.setdefault(col, []).append(c)
for collection, col_cards in sorted(by_collection.items()):
lines.append(f"## {collection} ({len(col_cards)} cards)")
lines.append("")
lines.append("| Term | Translation | Phonetic | Tags |")
lines.append("|------|-------------|----------|------|")
for c in col_cards:
term = (c.get("term") or "").replace("|", "\\|")
translation = (c.get("translation") or "").replace("|", "\\|")
phonetic = (c.get("phonetic") or "").replace("|", "\\|")
tags = (c.get("tags") or "").replace("|", "\\|")
lines.append(f"| **{term}** | {translation} | {phonetic} | {tags} |")
lines.append("")
lines.append("### Details")
lines.append("")
for c in col_cards:
term = c.get("term") or ""
phonetic_str = f" `{c['phonetic']}`" if c.get("phonetic") else ""
pos_str = f" *({c['part_of_speech']})*" if c.get("part_of_speech") else ""
lines.append(f"### {term}{phonetic_str}{pos_str}")
lines.append(f"- **Перевод**: {c.get('translation') or '—'}")
if c.get("definition_en"):
lines.append(f"- **Толкование**: {c['definition_en']}")
if c.get("context_sentence") or c.get("context"):
lines.append(f"- **Контекст**: *\"{c.get('context_sentence') or c.get('context')}\"*")
if c.get("notes"):
lines.append(f"- **Заметка**: {c['notes']}")
lines.append("")
return ("\n".join(lines) + "\n").encode("utf-8")