feat(trainer): add GitHub activity heatmap, 4-option quiz mode, active typing, and duplicate checks
This commit is contained in:
1 parent
d31c77fdd0
commit
2294121777
16 files changed
+4318
-685
No files matched your search
+151
@@ -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")
|
||||
Reference in new issue
Block a user