152 lines
6.1 KiB
Python
152 lines
6.1 KiB
Python
"""
|
|
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")
|