847 lines
27 KiB
Python
847 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
ABOUTME: Mochi API client for creating and managing flashcards, decks, and templates.
|
|
ABOUTME: Provides a Python interface to the Mochi.cards REST API with authentication and error handling.
|
|
|
|
Dependencies: requests
|
|
Install with: pip install requests
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
from typing import Dict, List, Optional, Any
|
|
from urllib.parse import urljoin, urlencode
|
|
import requests
|
|
from requests.auth import HTTPBasicAuth
|
|
|
|
|
|
class MochiAPIError(Exception):
|
|
"""Custom exception for Mochi API errors."""
|
|
pass
|
|
|
|
|
|
class PromptQualityError(Exception):
|
|
"""Exception raised when a prompt fails quality validation."""
|
|
pass
|
|
|
|
|
|
def validate_prompt_quality(question: str, answer: str, strict: bool = False) -> Dict[str, Any]:
|
|
"""
|
|
Validate a prompt against the 5 properties of effective prompts.
|
|
|
|
Based on Andy Matuschak's research on spaced repetition prompt design.
|
|
|
|
Args:
|
|
question: The question/front of the card
|
|
answer: The answer/back of the card
|
|
strict: If True, raise exception on validation failure
|
|
|
|
Returns:
|
|
Dict with 'valid' (bool), 'issues' (list), and 'suggestions' (list)
|
|
|
|
Raises:
|
|
PromptQualityError: If strict=True and validation fails
|
|
"""
|
|
issues = []
|
|
suggestions = []
|
|
|
|
# Check 1: Focused (one detail at a time)
|
|
if " and " in question or len(answer.split(",")) > 2:
|
|
issues.append("Prompt appears unfocused (tests multiple details)")
|
|
suggestions.append("Break into separate cards, one per detail")
|
|
|
|
# Check 2: Precise (specific, not vague)
|
|
vague_words = ["interesting", "important", "good", "bad", "tell me about", "what about"]
|
|
if any(word in question.lower() for word in vague_words):
|
|
issues.append("Question uses vague language")
|
|
suggestions.append("Be specific about what you're asking for")
|
|
|
|
# Check 3: Consistent (same answer each time)
|
|
variable_prompts = ["give an example", "name one", "describe a"]
|
|
if any(phrase in question.lower() for phrase in variable_prompts):
|
|
# This is OK for creative prompts, but warn
|
|
suggestions.append("Note: This prompt may produce variable answers (advanced technique)")
|
|
|
|
# Check 4: Binary questions (usually poor)
|
|
if question.strip().startswith(("Is ", "Does ", "Can ", "Will ", "Do ", "Are ")):
|
|
issues.append("Binary question (yes/no) - produces shallow understanding")
|
|
suggestions.append("Rephrase as open-ended question starting with What/Why/How/When")
|
|
|
|
# Check 5: Pattern-matchable (too long, answerable by syntax)
|
|
if len(question) > 200:
|
|
issues.append("Question is very long - may be answerable by pattern matching")
|
|
suggestions.append("Keep questions short and simple")
|
|
|
|
# Check 6: Trivial (too easy, no retrieval)
|
|
trivial_indicators = [
|
|
"what does", "is", "acronym", "stands for",
|
|
"true or false", "correct"
|
|
]
|
|
if any(indicator in question.lower() for indicator in trivial_indicators) and len(answer) < 20:
|
|
suggestions.append("Verify this requires memory retrieval, not trivial knowledge")
|
|
|
|
valid = len(issues) == 0
|
|
|
|
result = {
|
|
"valid": valid,
|
|
"issues": issues,
|
|
"suggestions": suggestions
|
|
}
|
|
|
|
if strict and not valid:
|
|
error_msg = "Prompt quality validation failed:\n"
|
|
error_msg += "\n".join(f" - {issue}" for issue in issues)
|
|
error_msg += "\n\nSuggestions:\n"
|
|
error_msg += "\n".join(f" - {suggestion}" for suggestion in suggestions)
|
|
raise PromptQualityError(error_msg)
|
|
|
|
return result
|
|
|
|
|
|
def create_conceptual_lens_cards(
|
|
api: "MochiAPI",
|
|
concept: str,
|
|
deck_id: str,
|
|
lenses: Optional[Dict[str, str]] = None,
|
|
base_tags: Optional[List[str]] = None
|
|
) -> List[Dict]:
|
|
"""
|
|
Create multiple cards for a concept using the 5 conceptual lenses approach.
|
|
|
|
This creates robust understanding by examining a concept from multiple angles:
|
|
- Attributes: What's always/sometimes/never true?
|
|
- Similarities: How does it relate to adjacent concepts?
|
|
- Parts/Wholes: Examples, sub-concepts, categories
|
|
- Causes/Effects: What does it do? When is it used?
|
|
- Significance: Why does it matter personally?
|
|
|
|
Args:
|
|
api: MochiAPI instance
|
|
concept: The concept to create cards about
|
|
deck_id: Target deck ID
|
|
lenses: Dict mapping lens names to specific prompts (optional - will use defaults)
|
|
base_tags: Common tags for all cards (concept name will be added automatically)
|
|
|
|
Returns:
|
|
List of created card objects
|
|
|
|
Example:
|
|
>>> api = MochiAPI()
|
|
>>> cards = create_conceptual_lens_cards(
|
|
... api,
|
|
... concept="dependency injection",
|
|
... deck_id="deck123",
|
|
... lenses={
|
|
... "attributes": "Dependencies are provided from outside",
|
|
... "similarities": "Different from service locator (push vs pull)",
|
|
... "parts": "Pass DB connection to constructor instead of creating it",
|
|
... "causes": "Makes code testable by allowing mock dependencies",
|
|
... "significance": "Essential for writing testable FastAPI endpoints"
|
|
... }
|
|
... )
|
|
"""
|
|
if base_tags is None:
|
|
base_tags = []
|
|
|
|
# Add concept as tag
|
|
concept_tag = concept.lower().replace(" ", "-")
|
|
all_tags = base_tags + [concept_tag]
|
|
|
|
# Default lens questions if not provided
|
|
default_questions = {
|
|
"attributes": f"What is the core attribute of {concept}?",
|
|
"similarities": f"How does {concept} differ from similar concepts?",
|
|
"parts": f"Give a concrete example of {concept}",
|
|
"causes": f"What problem does {concept} solve?",
|
|
"significance": f"When would you use {concept} in your work?"
|
|
}
|
|
|
|
cards = []
|
|
|
|
for lens_name, question in default_questions.items():
|
|
# Use custom lens content if provided, otherwise skip if not in lenses
|
|
if lenses and lens_name not in lenses:
|
|
continue
|
|
|
|
answer = lenses.get(lens_name, "") if lenses else ""
|
|
|
|
if not answer:
|
|
# Skip if no answer provided
|
|
continue
|
|
|
|
# Create the card
|
|
content = f"# {question}\n---\n{answer}"
|
|
|
|
card = api.create_card(
|
|
content=content,
|
|
deck_id=deck_id,
|
|
manual_tags=all_tags + [lens_name]
|
|
)
|
|
cards.append(card)
|
|
|
|
return cards
|
|
|
|
|
|
def break_into_atomic_prompts(complex_prompt: str, answer: str) -> List[Dict[str, str]]:
|
|
"""
|
|
Suggest how to break a complex prompt into atomic, focused prompts.
|
|
|
|
This is a heuristic function that identifies common patterns of unfocused prompts.
|
|
|
|
Args:
|
|
complex_prompt: The unfocused question
|
|
answer: The complex answer
|
|
|
|
Returns:
|
|
List of dicts with 'question' and 'answer' keys, or empty list if can't break down
|
|
|
|
Example:
|
|
>>> prompts = break_into_atomic_prompts(
|
|
... "What are Python decorators and what syntax do they use?",
|
|
... "Functions that modify behavior, use @ syntax"
|
|
... )
|
|
>>> len(prompts)
|
|
2
|
|
"""
|
|
suggestions = []
|
|
|
|
# Pattern 1: "What are X and Y" or "What do X and Y"
|
|
if " and " in complex_prompt:
|
|
parts = complex_prompt.split(" and ")
|
|
if len(parts) == 2:
|
|
# Try to split answer as well
|
|
answer_parts = answer.split(",")
|
|
if len(answer_parts) >= 2:
|
|
suggestions.append({
|
|
"question": parts[0].strip() + "?",
|
|
"answer": answer_parts[0].strip()
|
|
})
|
|
suggestions.append({
|
|
"question": "What " + parts[1].strip(),
|
|
"answer": ", ".join(answer_parts[1:]).strip()
|
|
})
|
|
|
|
# Pattern 2: Multiple comma-separated items in answer
|
|
if "," in answer:
|
|
items = [item.strip() for item in answer.split(",")]
|
|
if len(items) > 2:
|
|
# Suggest creating one card per item
|
|
for item in items:
|
|
suggestions.append({
|
|
"question": f"{complex_prompt} (focus on {item.split()[0]})",
|
|
"answer": item,
|
|
"note": "Break into individual cards, one per item"
|
|
})
|
|
|
|
return suggestions
|
|
|
|
|
|
def create_procedural_cards(
|
|
api: "MochiAPI",
|
|
procedure_name: str,
|
|
deck_id: str,
|
|
transitions: Optional[List[Dict[str, str]]] = None,
|
|
rationales: Optional[List[Dict[str, str]]] = None,
|
|
timings: Optional[List[Dict[str, str]]] = None,
|
|
base_tags: Optional[List[str]] = None
|
|
) -> List[Dict]:
|
|
"""
|
|
Create cards for procedural knowledge focusing on transitions, rationales, and timing.
|
|
|
|
Avoids rote "step 1, step 2" memorization in favor of understanding.
|
|
|
|
Args:
|
|
api: MochiAPI instance
|
|
procedure_name: Name of the procedure/process
|
|
deck_id: Target deck ID
|
|
transitions: List of dicts with 'condition' and 'next_step'
|
|
rationales: List of dicts with 'action' and 'reason'
|
|
timings: List of dicts with 'phase' and 'duration'
|
|
base_tags: Common tags for all cards
|
|
|
|
Returns:
|
|
List of created card objects
|
|
|
|
Example:
|
|
>>> cards = create_procedural_cards(
|
|
... api,
|
|
... procedure_name="sourdough bread making",
|
|
... deck_id="deck123",
|
|
... transitions=[
|
|
... {"condition": "flour is fully hydrated", "next_step": "add salt"}
|
|
... ],
|
|
... rationales=[
|
|
... {"action": "autolyse before adding salt",
|
|
... "reason": "salt inhibits gluten development"}
|
|
... ],
|
|
... timings=[
|
|
... {"phase": "bulk fermentation", "duration": "4-6 hours at room temp"}
|
|
... ]
|
|
... )
|
|
"""
|
|
if base_tags is None:
|
|
base_tags = []
|
|
|
|
procedure_tag = procedure_name.lower().replace(" ", "-")
|
|
all_tags = base_tags + [procedure_tag]
|
|
|
|
cards = []
|
|
|
|
# Create transition cards
|
|
if transitions:
|
|
for trans in transitions:
|
|
content = f"# When do you know it's time to {trans['next_step']} in {procedure_name}?\n---\n{trans['condition']}"
|
|
card = api.create_card(
|
|
content=content,
|
|
deck_id=deck_id,
|
|
manual_tags=all_tags + ["transitions"]
|
|
)
|
|
cards.append(card)
|
|
|
|
# Create rationale cards
|
|
if rationales:
|
|
for rat in rationales:
|
|
content = f"# Why do you {rat['action']} in {procedure_name}?\n---\n{rat['reason']}"
|
|
card = api.create_card(
|
|
content=content,
|
|
deck_id=deck_id,
|
|
manual_tags=all_tags + ["rationale"]
|
|
)
|
|
cards.append(card)
|
|
|
|
# Create timing cards
|
|
if timings:
|
|
for timing in timings:
|
|
content = f"# How long does {timing['phase']} take in {procedure_name}?\n---\n{timing['duration']}"
|
|
card = api.create_card(
|
|
content=content,
|
|
deck_id=deck_id,
|
|
manual_tags=all_tags + ["timing"]
|
|
)
|
|
cards.append(card)
|
|
|
|
return cards
|
|
|
|
|
|
class MochiAPI:
|
|
"""Client for interacting with the Mochi.cards API."""
|
|
|
|
BASE_URL = "https://app.mochi.cards/api/"
|
|
|
|
def __init__(self, api_key: Optional[str] = None):
|
|
"""
|
|
Initialize the Mochi API client.
|
|
|
|
Args:
|
|
api_key: Mochi API key. If not provided, reads from MOCHI_API_KEY environment variable.
|
|
|
|
Raises:
|
|
MochiAPIError: If no API key is provided or found in environment.
|
|
"""
|
|
self.api_key = api_key or os.getenv("MOCHI_API_KEY")
|
|
if not self.api_key:
|
|
raise MochiAPIError(
|
|
"No API key provided. Set MOCHI_API_KEY environment variable or pass api_key parameter."
|
|
)
|
|
self.auth = HTTPBasicAuth(self.api_key, "")
|
|
self.headers = {
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json"
|
|
}
|
|
|
|
def _request(self, method: str, endpoint: str, data: Optional[Dict] = None, params: Optional[Dict] = None) -> Any:
|
|
"""
|
|
Make a request to the Mochi API.
|
|
|
|
Args:
|
|
method: HTTP method (GET, POST, DELETE)
|
|
endpoint: API endpoint path
|
|
data: Request body data
|
|
params: Query parameters
|
|
|
|
Returns:
|
|
Response data as dict or None for DELETE requests
|
|
|
|
Raises:
|
|
MochiAPIError: If the request fails
|
|
"""
|
|
url = urljoin(self.BASE_URL, endpoint.lstrip("/"))
|
|
|
|
try:
|
|
response = requests.request(
|
|
method=method,
|
|
url=url,
|
|
auth=self.auth,
|
|
headers=self.headers,
|
|
json=data,
|
|
params=params,
|
|
timeout=30
|
|
)
|
|
|
|
if response.status_code == 204:
|
|
return None
|
|
|
|
if not response.ok:
|
|
error_data = {}
|
|
try:
|
|
error_data = response.json()
|
|
except:
|
|
pass
|
|
|
|
error_msg = f"API request failed with status {response.status_code}"
|
|
if "errors" in error_data:
|
|
error_msg += f": {error_data['errors']}"
|
|
raise MochiAPIError(error_msg)
|
|
|
|
if response.text:
|
|
return response.json()
|
|
return None
|
|
|
|
except requests.RequestException as e:
|
|
raise MochiAPIError(f"Network error: {str(e)}")
|
|
|
|
# Card operations
|
|
|
|
def create_card(
|
|
self,
|
|
content: str,
|
|
deck_id: str,
|
|
template_id: Optional[str] = None,
|
|
fields: Optional[Dict[str, Dict[str, str]]] = None,
|
|
archived: bool = False,
|
|
review_reverse: bool = False,
|
|
pos: Optional[str] = None,
|
|
manual_tags: Optional[List[str]] = None
|
|
) -> Dict:
|
|
"""
|
|
Create a new card.
|
|
|
|
Args:
|
|
content: Markdown content of the card
|
|
deck_id: ID of the deck to add the card to
|
|
template_id: Optional template ID to use
|
|
fields: Optional dict of field IDs to field values
|
|
archived: Whether the card is archived
|
|
review_reverse: Whether to review in reverse order
|
|
pos: Relative position within deck (lexicographic sorting)
|
|
manual_tags: List of tags (without # prefix)
|
|
|
|
Returns:
|
|
Created card data
|
|
|
|
Example:
|
|
>>> api = MochiAPI()
|
|
>>> card = api.create_card(
|
|
... content="# What is Python?\\n---\\nA high-level programming language",
|
|
... deck_id="abc123",
|
|
... manual_tags=["programming", "python"]
|
|
... )
|
|
"""
|
|
data = {
|
|
"content": content,
|
|
"deck-id": deck_id,
|
|
"archived?": archived,
|
|
"review-reverse?": review_reverse
|
|
}
|
|
|
|
if template_id:
|
|
data["template-id"] = template_id
|
|
if fields:
|
|
data["fields"] = fields
|
|
if pos:
|
|
data["pos"] = pos
|
|
if manual_tags:
|
|
data["manual-tags"] = manual_tags
|
|
|
|
return self._request("POST", "/cards/", data=data)
|
|
|
|
def get_card(self, card_id: str) -> Dict:
|
|
"""
|
|
Retrieve a card by ID.
|
|
|
|
Args:
|
|
card_id: The card ID
|
|
|
|
Returns:
|
|
Card data
|
|
"""
|
|
return self._request("GET", f"/cards/{card_id}")
|
|
|
|
def list_cards(
|
|
self,
|
|
deck_id: Optional[str] = None,
|
|
limit: int = 10,
|
|
bookmark: Optional[str] = None
|
|
) -> Dict:
|
|
"""
|
|
List cards with optional filtering and pagination.
|
|
|
|
Args:
|
|
deck_id: Optional deck ID to filter by
|
|
limit: Number of cards per page (1-100, default 10)
|
|
bookmark: Pagination cursor from previous request
|
|
|
|
Returns:
|
|
Dict with 'docs' (list of cards) and 'bookmark' (for next page)
|
|
"""
|
|
params = {"limit": limit}
|
|
if deck_id:
|
|
params["deck-id"] = deck_id
|
|
if bookmark:
|
|
params["bookmark"] = bookmark
|
|
|
|
return self._request("GET", "/cards/", params=params)
|
|
|
|
def update_card(
|
|
self,
|
|
card_id: str,
|
|
content: Optional[str] = None,
|
|
deck_id: Optional[str] = None,
|
|
template_id: Optional[str] = None,
|
|
fields: Optional[Dict[str, Dict[str, str]]] = None,
|
|
archived: Optional[bool] = None,
|
|
trashed: Optional[str] = None,
|
|
review_reverse: Optional[bool] = None,
|
|
pos: Optional[str] = None,
|
|
manual_tags: Optional[List[str]] = None
|
|
) -> Dict:
|
|
"""
|
|
Update an existing card.
|
|
|
|
Args:
|
|
card_id: The card ID to update
|
|
content: New markdown content
|
|
deck_id: Move to different deck
|
|
template_id: Change template
|
|
fields: Update field values
|
|
archived: Archive/unarchive
|
|
trashed: ISO 8601 timestamp to trash, or None to untrash
|
|
review_reverse: Update reverse review setting
|
|
pos: Update position
|
|
manual_tags: Update tags (replaces existing)
|
|
|
|
Returns:
|
|
Updated card data
|
|
"""
|
|
data = {}
|
|
if content is not None:
|
|
data["content"] = content
|
|
if deck_id is not None:
|
|
data["deck-id"] = deck_id
|
|
if template_id is not None:
|
|
data["template-id"] = template_id
|
|
if fields is not None:
|
|
data["fields"] = fields
|
|
if archived is not None:
|
|
data["archived?"] = archived
|
|
if trashed is not None:
|
|
data["trashed?"] = trashed
|
|
if review_reverse is not None:
|
|
data["review-reverse?"] = review_reverse
|
|
if pos is not None:
|
|
data["pos"] = pos
|
|
if manual_tags is not None:
|
|
data["manual-tags"] = manual_tags
|
|
|
|
return self._request("POST", f"/cards/{card_id}", data=data)
|
|
|
|
def delete_card(self, card_id: str) -> None:
|
|
"""
|
|
Permanently delete a card.
|
|
|
|
Warning: This cannot be undone. Consider using update_card with trashed parameter for soft delete.
|
|
|
|
Args:
|
|
card_id: The card ID to delete
|
|
"""
|
|
self._request("DELETE", f"/cards/{card_id}")
|
|
|
|
# Deck operations
|
|
|
|
def create_deck(
|
|
self,
|
|
name: str,
|
|
parent_id: Optional[str] = None,
|
|
sort: Optional[int] = None,
|
|
archived: bool = False,
|
|
sort_by: str = "none",
|
|
cards_view: str = "list",
|
|
show_sides: bool = True,
|
|
sort_by_direction: bool = False,
|
|
review_reverse: bool = False
|
|
) -> Dict:
|
|
"""
|
|
Create a new deck.
|
|
|
|
Args:
|
|
name: Deck name
|
|
parent_id: Optional parent deck ID for nesting
|
|
sort: Numeric sort order
|
|
archived: Whether deck is archived
|
|
sort_by: How to sort cards (none, lexicographically, created-at, updated-at, etc.)
|
|
cards_view: Display mode (list, grid, note, column)
|
|
show_sides: Show all sides of cards
|
|
sort_by_direction: Reverse sort order
|
|
review_reverse: Review cards in reverse
|
|
|
|
Returns:
|
|
Created deck data
|
|
"""
|
|
data = {
|
|
"name": name,
|
|
"archived?": archived,
|
|
"sort-by": sort_by,
|
|
"cards-view": cards_view,
|
|
"show-sides?": show_sides,
|
|
"sort-by-direction": sort_by_direction,
|
|
"review-reverse?": review_reverse
|
|
}
|
|
|
|
if parent_id:
|
|
data["parent-id"] = parent_id
|
|
if sort is not None:
|
|
data["sort"] = sort
|
|
|
|
return self._request("POST", "/decks/", data=data)
|
|
|
|
def get_deck(self, deck_id: str) -> Dict:
|
|
"""
|
|
Retrieve a deck by ID.
|
|
|
|
Args:
|
|
deck_id: The deck ID
|
|
|
|
Returns:
|
|
Deck data
|
|
"""
|
|
return self._request("GET", f"/decks/{deck_id}")
|
|
|
|
def list_decks(self, bookmark: Optional[str] = None) -> Dict:
|
|
"""
|
|
List all decks with pagination.
|
|
|
|
Args:
|
|
bookmark: Pagination cursor from previous request
|
|
|
|
Returns:
|
|
Dict with 'docs' (list of decks) and 'bookmark' (for next page)
|
|
"""
|
|
params = {}
|
|
if bookmark:
|
|
params["bookmark"] = bookmark
|
|
|
|
return self._request("GET", "/decks/", params=params)
|
|
|
|
def update_deck(
|
|
self,
|
|
deck_id: str,
|
|
name: Optional[str] = None,
|
|
parent_id: Optional[str] = None,
|
|
sort: Optional[int] = None,
|
|
archived: Optional[bool] = None,
|
|
trashed: Optional[str] = None,
|
|
sort_by: Optional[str] = None,
|
|
cards_view: Optional[str] = None,
|
|
show_sides: Optional[bool] = None,
|
|
sort_by_direction: Optional[bool] = None,
|
|
review_reverse: Optional[bool] = None
|
|
) -> Dict:
|
|
"""
|
|
Update an existing deck.
|
|
|
|
Args:
|
|
deck_id: The deck ID to update
|
|
name: New name
|
|
parent_id: Move to different parent
|
|
sort: Update sort order
|
|
archived: Archive/unarchive
|
|
trashed: ISO 8601 timestamp to trash, or None to untrash
|
|
sort_by: Update sort method
|
|
cards_view: Update view mode
|
|
show_sides: Update show sides setting
|
|
sort_by_direction: Update sort direction
|
|
review_reverse: Update reverse review setting
|
|
|
|
Returns:
|
|
Updated deck data
|
|
"""
|
|
data = {}
|
|
if name is not None:
|
|
data["name"] = name
|
|
if parent_id is not None:
|
|
data["parent-id"] = parent_id
|
|
if sort is not None:
|
|
data["sort"] = sort
|
|
if archived is not None:
|
|
data["archived?"] = archived
|
|
if trashed is not None:
|
|
data["trashed?"] = trashed
|
|
if sort_by is not None:
|
|
data["sort-by"] = sort_by
|
|
if cards_view is not None:
|
|
data["cards-view"] = cards_view
|
|
if show_sides is not None:
|
|
data["show-sides?"] = show_sides
|
|
if sort_by_direction is not None:
|
|
data["sort-by-direction"] = sort_by_direction
|
|
if review_reverse is not None:
|
|
data["review-reverse?"] = review_reverse
|
|
|
|
return self._request("POST", f"/decks/{deck_id}", data=data)
|
|
|
|
def delete_deck(self, deck_id: str) -> None:
|
|
"""
|
|
Permanently delete a deck.
|
|
|
|
Warning: This cannot be undone. Cards and child decks are NOT deleted.
|
|
Consider using update_deck with trashed parameter for soft delete.
|
|
|
|
Args:
|
|
deck_id: The deck ID to delete
|
|
"""
|
|
self._request("DELETE", f"/decks/{deck_id}")
|
|
|
|
# Template operations
|
|
|
|
def create_template(
|
|
self,
|
|
name: str,
|
|
content: str,
|
|
fields: Dict[str, Dict[str, Any]],
|
|
pos: Optional[str] = None,
|
|
style: Optional[Dict[str, str]] = None,
|
|
options: Optional[Dict[str, bool]] = None
|
|
) -> Dict:
|
|
"""
|
|
Create a new template.
|
|
|
|
Args:
|
|
name: Template name
|
|
content: Markdown content with field placeholders like << Field name >>
|
|
fields: Dict of field definitions
|
|
pos: Position for sorting
|
|
style: Style options (e.g., text-alignment)
|
|
options: Template options (e.g., show-sides-separately?)
|
|
|
|
Returns:
|
|
Created template data
|
|
|
|
Example:
|
|
>>> fields = {
|
|
... "front": {
|
|
... "id": "front",
|
|
... "name": "Front",
|
|
... "type": "text",
|
|
... "pos": "a"
|
|
... },
|
|
... "back": {
|
|
... "id": "back",
|
|
... "name": "Back",
|
|
... "type": "text",
|
|
... "pos": "b",
|
|
... "options": {"multi-line?": True}
|
|
... }
|
|
... }
|
|
>>> template = api.create_template(
|
|
... name="Basic Flashcard",
|
|
... content="# << Front >>\\n---\\n<< Back >>",
|
|
... fields=fields
|
|
... )
|
|
"""
|
|
data = {
|
|
"name": name,
|
|
"content": content,
|
|
"fields": fields
|
|
}
|
|
|
|
if pos:
|
|
data["pos"] = pos
|
|
if style:
|
|
data["style"] = style
|
|
if options:
|
|
data["options"] = options
|
|
|
|
return self._request("POST", "/templates/", data=data)
|
|
|
|
def get_template(self, template_id: str) -> Dict:
|
|
"""
|
|
Retrieve a template by ID.
|
|
|
|
Args:
|
|
template_id: The template ID
|
|
|
|
Returns:
|
|
Template data
|
|
"""
|
|
return self._request("GET", f"/templates/{template_id}")
|
|
|
|
def list_templates(self, bookmark: Optional[str] = None) -> Dict:
|
|
"""
|
|
List all templates with pagination.
|
|
|
|
Args:
|
|
bookmark: Pagination cursor from previous request
|
|
|
|
Returns:
|
|
Dict with 'docs' (list of templates) and 'bookmark' (for next page)
|
|
"""
|
|
params = {}
|
|
if bookmark:
|
|
params["bookmark"] = bookmark
|
|
|
|
return self._request("GET", "/templates/", params=params)
|
|
|
|
|
|
def main():
|
|
"""CLI interface for testing the Mochi API."""
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python mochi_api.py <command> [args...]")
|
|
print("\nCommands:")
|
|
print(" list-decks - List all decks")
|
|
print(" list-cards <deck-id> - List cards in a deck")
|
|
print(" create-deck <name> - Create a new deck")
|
|
print(" create-card <deck-id> <content> - Create a card")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
api = MochiAPI()
|
|
command = sys.argv[1]
|
|
|
|
if command == "list-decks":
|
|
result = api.list_decks()
|
|
print(json.dumps(result, indent=2))
|
|
|
|
elif command == "list-cards":
|
|
if len(sys.argv) < 3:
|
|
print("Error: deck-id required")
|
|
sys.exit(1)
|
|
result = api.list_cards(deck_id=sys.argv[2])
|
|
print(json.dumps(result, indent=2))
|
|
|
|
elif command == "create-deck":
|
|
if len(sys.argv) < 3:
|
|
print("Error: deck name required")
|
|
sys.exit(1)
|
|
result = api.create_deck(name=sys.argv[2])
|
|
print(json.dumps(result, indent=2))
|
|
|
|
elif command == "create-card":
|
|
if len(sys.argv) < 4:
|
|
print("Error: deck-id and content required")
|
|
sys.exit(1)
|
|
result = api.create_card(deck_id=sys.argv[2], content=sys.argv[3])
|
|
print(json.dumps(result, indent=2))
|
|
|
|
else:
|
|
print(f"Unknown command: {command}")
|
|
sys.exit(1)
|
|
|
|
except MochiAPIError as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|