import asyncio from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from app.auth import require_user from app.database import get_db from app.tts import get_audio_hash, prewarm_card_audio router = APIRouter(tags=["Cards & Collections"]) class CardCreateRequest(BaseModel): term: str = Field(..., min_length=1) translation: str = Field(..., min_length=1) context_sentence: Optional[str] = "" phonetic: Optional[str] = "" audio_url: Optional[str] = "" part_of_speech: Optional[str] = "" definition_en: Optional[str] = "" tags: Optional[str] = "general" notes: Optional[str] = "" collection: Optional[str] = "General" class CardUpdateRequest(BaseModel): term: Optional[str] = None translation: Optional[str] = None context_sentence: Optional[str] = None phonetic: Optional[str] = None audio_url: Optional[str] = None part_of_speech: Optional[str] = None definition_en: Optional[str] = None tags: Optional[str] = None notes: Optional[str] = None collection: Optional[str] = None class BatchActionRequest(BaseModel): card_ids: List[int] = Field(..., min_length=1) action: str = Field(..., pattern="^(set_collection|add_tags|delete)$") value: Optional[str] = "" @router.get("/api/collections") async def api_get_collections(user: dict = Depends(require_user)): """Returns all collections for the current user with card count.""" with get_db() as conn: rows = conn.execute( """ SELECT collection, COUNT(*) as count FROM vocabulary_cards WHERE user_id = ? GROUP BY collection ORDER BY count DESC, collection ASC """, (user["id"],), ).fetchall() return [{"name": r["collection"], "collection": r["collection"], "count": r["count"]} for r in rows] @router.get("/api/cards/check-duplicate") async def api_check_duplicate(term: str = Query(..., min_length=1), user: dict = Depends(require_user)): clean_term = term.strip().lower() with get_db() as conn: row = conn.execute( "SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = ?", (user["id"], clean_term), ).fetchone() if row: return {"exists": True, "card": dict(row)} return {"exists": False, "card": None} @router.get("/api/cards") async def api_get_cards( search: Optional[str] = None, tag: Optional[str] = None, collection: Optional[str] = None, srs_state: Optional[str] = None, limit: int = 100, offset: int = 0, user: dict = Depends(require_user), ): with get_db() as conn: query = """ SELECT id, term, translation, context_sentence, phonetic, audio_url, part_of_speech, definition_en, tags, notes, collection, srs_reps, srs_interval, srs_ease, srs_next_review, srs_last_review, srs_state, error_count, created_at, updated_at FROM vocabulary_cards WHERE user_id = ? """ params = [user["id"]] if search: s_clean = f"%{search.strip()}%" query += " AND (term LIKE ? OR translation LIKE ? OR context_sentence LIKE ? OR notes LIKE ?)" params.extend([s_clean, s_clean, s_clean, s_clean]) if tag: t_clean = f"%{tag.strip()}%" query += " AND tags LIKE ?" params.append(t_clean) if collection: query += " AND collection = ?" params.append(collection.strip()) if srs_state: query += " AND srs_state = ?" params.append(srs_state.strip()) # Total count before limit count_query = f"SELECT COUNT(*) FROM ({query})" total = conn.execute(count_query, params).fetchone()[0] query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) rows = conn.execute(query, params).fetchall() cards = [] for r in rows: c = dict(r) if not c.get("audio_url"): c["audio_url"] = f"/api/audio/{get_audio_hash(c['term'], 'en', 1.0)}.wav" if c.get("context_sentence"): c["context_audio_url"] = f"/api/audio/{get_audio_hash(c['context_sentence'], 'en', 1.0)}.wav" cards.append(c) return {"total": total, "cards": cards} @router.post("/api/cards") async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_user)): clean_term = card.term.strip() with get_db() as conn: existing = conn.execute( "SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))", (user["id"], clean_term), ).fetchone() if existing: # Word already exists in library! existing_row = dict(existing) new_ctx = (card.context_sentence or "").strip() old_ctx = (existing_row.get("context_sentence") or "").strip() updated_ctx = old_ctx if new_ctx and new_ctx not in old_ctx: updated_ctx = f"{old_ctx}\n\n{new_ctx}" if old_ctx else new_ctx # Merge tags old_tags = set([t.strip() for t in (existing_row.get("tags") or "").split(",") if t.strip()]) new_tags = [t.strip() for t in (card.tags or "").split(",") if t.strip()] for t in new_tags: old_tags.add(t) updated_tags = ", ".join(sorted(old_tags)) if old_tags else "general" conn.execute( """ UPDATE vocabulary_cards SET context_sentence = ?, tags = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, (updated_ctx, updated_tags, existing_row["id"]), ) updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (existing_row["id"],)).fetchone() res = dict(updated_row) if not res.get("audio_url"): res["audio_url"] = f"/api/audio/{get_audio_hash(res['term'], 'en', 1.0)}.wav" if res.get("context_sentence"): res["context_audio_url"] = f"/api/audio/{get_audio_hash(res['context_sentence'], 'en', 1.0)}.wav" res["already_exists"] = True # Pre-warm word and context audio in background asyncio.create_task(prewarm_card_audio(clean_term, card.context_sentence)) return res audio_url_val = (card.audio_url or "").strip() if not audio_url_val: audio_url_val = f"/api/audio/{get_audio_hash(clean_term, 'en', 1.0)}.wav" cursor = conn.execute( """ INSERT INTO vocabulary_cards ( user_id, term, translation, context_sentence, phonetic, audio_url, part_of_speech, definition_en, tags, notes, collection, srs_reps, srs_interval, srs_ease, srs_state, error_count ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 2.5, 'new', 0) """, ( user["id"], clean_term, card.translation.strip(), (card.context_sentence or "").strip(), (card.phonetic or "").strip(), audio_url_val, (card.part_of_speech or "").strip(), (card.definition_en or "").strip(), (card.tags or "general").strip(), (card.notes or "").strip(), (card.collection or "General").strip(), ), ) new_id = cursor.lastrowid row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (new_id,)).fetchone() res = dict(row) if not res.get("audio_url"): res["audio_url"] = f"/api/audio/{get_audio_hash(res['term'], 'en', 1.0)}.wav" if res.get("context_sentence"): res["context_audio_url"] = f"/api/audio/{get_audio_hash(res['context_sentence'], 'en', 1.0)}.wav" # Pre-warm word and context audio in background asyncio.create_task(prewarm_card_audio(clean_term, card.context_sentence)) return res @router.get("/api/cards/{card_id}") async def api_get_card(card_id: int, user: dict = Depends(require_user)): with get_db() as conn: row = conn.execute( "SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?", (card_id, user["id"]), ).fetchone() if not row: raise HTTPException(status_code=404, detail="Card not found") return dict(row) @router.put("/api/cards/{card_id}") async def api_update_card(card_id: int, update: CardUpdateRequest, user: dict = Depends(require_user)): with get_db() as conn: row = conn.execute( "SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?", (card_id, user["id"]), ).fetchone() if not row: raise HTTPException(status_code=404, detail="Card not found") current = dict(row) def pick(new_val, current_val): return new_val.strip() if new_val is not None else current_val conn.execute( """ UPDATE vocabulary_cards SET term = ?, translation = ?, context_sentence = ?, phonetic = ?, audio_url = ?, part_of_speech = ?, definition_en = ?, tags = ?, notes = ?, collection = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? """, ( pick(update.term, current["term"]), pick(update.translation, current["translation"]), pick(update.context_sentence, current.get("context_sentence", "")), pick(update.phonetic, current.get("phonetic", "")), pick(update.audio_url, current.get("audio_url", "")), pick(update.part_of_speech, current.get("part_of_speech", "")), pick(update.definition_en, current.get("definition_en", "")), pick(update.tags, current.get("tags", "")), pick(update.notes, current.get("notes", "")), pick(update.collection, current.get("collection", "General")), card_id, user["id"], ), ) updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (card_id,)).fetchone() return dict(updated_row) @router.delete("/api/cards/{card_id}") async def api_delete_card(card_id: int, user: dict = Depends(require_user)): with get_db() as conn: cursor = conn.execute( "DELETE FROM vocabulary_cards WHERE id = ? AND user_id = ?", (card_id, user["id"]), ) if cursor.rowcount == 0: raise HTTPException(status_code=404, detail="Card not found") return {"status": "ok", "deleted_id": card_id} @router.post("/api/cards/batch") async def api_batch_action(req: BatchActionRequest, user: dict = Depends(require_user)): if not req.card_ids: raise HTTPException(status_code=400, detail="No card IDs provided") with get_db() as conn: placeholders = ",".join("?" * len(req.card_ids)) params_base = list(req.card_ids) + [user["id"]] if req.action == "delete": cursor = conn.execute( f"DELETE FROM vocabulary_cards WHERE id IN ({placeholders}) AND user_id = ?", params_base, ) return {"status": "ok", "action": "delete", "affected": cursor.rowcount} elif req.action == "set_collection": collection_val = (req.value or "General").strip() cursor = conn.execute( f"UPDATE vocabulary_cards SET collection = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders}) AND user_id = ?", [collection_val] + params_base, ) return {"status": "ok", "action": "set_collection", "affected": cursor.rowcount} elif req.action == "add_tags": new_tags_raw = req.value or "" new_tags = [t.strip() for t in new_tags_raw.split(",") if t.strip()] if not new_tags: return {"status": "ok", "action": "add_tags", "affected": 0} rows = conn.execute( f"SELECT id, tags FROM vocabulary_cards WHERE id IN ({placeholders}) AND user_id = ?", params_base, ).fetchall() affected = 0 for r in rows: existing = [t.strip() for t in (r["tags"] or "").split(",") if t.strip()] combined = existing + [t for t in new_tags if t not in existing] conn.execute( "UPDATE vocabulary_cards SET tags = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", (", ".join(combined), r["id"]), ) affected += 1 return {"status": "ok", "action": "add_tags", "affected": affected} @router.get("/api/tags") async def api_get_tags(user: dict = Depends(require_user)): with get_db() as conn: rows = conn.execute( "SELECT tags FROM vocabulary_cards WHERE user_id = ?", (user["id"],), ).fetchall() tag_counts = {} for r in rows: if r["tags"]: for t in r["tags"].split(","): clean = t.strip() if clean: tag_counts[clean] = tag_counts.get(clean, 0) + 1 sorted_tags = sorted( [{"tag": k, "count": v} for k, v in tag_counts.items()], key=lambda x: x["count"], reverse=True, ) return sorted_tags