mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-13 07:50:58 +01:00
Phase A of TODO-coach.md (decisions 1-3). The skill code is now a package here with the same behaviour as before. Nothing new is added. ~/.claude/skills/leetcode and ~/.local/bin/leetcode become symlinks into the package, created by scripts/link_leetcode_coach.sh, which install.sh runs. - Package imports replace the bare sibling imports. To stay under the 250-line cap, the Stop-hook detector is split into gate_detector.py and stop_gate.py, the card rules move into card_rules.py, and the prompt text moves into local_prompt.py. - local_coach uses gate_detector directly. It no longer loads the hook file with importlib. - make_cards runs on the system python: a genanki pin (pyproject and requirements) replaces the `uv run --script` header. The note guid is passed to genanki.Note explicitly, so CardNote is no longer subclassed; the guid values are unchanged. - stop_gate also accepts the package path as a skill-load marker, because the harness may print the resolved symlink path. - Every swallowing except now logs a warning. A NullHandler on the package logger keeps stdout/stderr the same as before. - Modules whose prints are their interface (REPL, local session, card builder, fetcher, eval, Stop hook) get T201 per-file ignores, and their tests assert on stdout. - hook_shims/ holds the 3-line ~/.claude/hooks shim. It is excluded from ruff, mypy, pylint and coverage, and a test runs it end to end. - Pre-commit: a requirements-sync entry for each package, leetcode_coach and genanki added to the pylint hook, and the name-tests-test exclude widened. coverage and ruff are bumped to the latest stable releases because the dependency-freshness gate requires it. Verification: 111 coach tests pass with 100% branch coverage. ruff, mypy strict and pylint are clean (10.00). On the 370 assistant replies in 39 real /leetcode transcripts, the old hook and stop_gate give the same verdict (0 mismatches, 20 blocked). fetch_problem output (live two-sum) and make_cards --dry-run output are byte-identical to the old skill, and so are the note guids. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
141 lines
4.8 KiB
Python
141 lines
4.8 KiB
Python
"""Push cards straight into the running Anki desktop via AnkiConnect.
|
|
|
|
Stdlib only. Anki must be open with the AnkiConnect add-on (2055492159);
|
|
`available()` is the probe, everything else assumes it. Notes are matched by
|
|
(deck, Problem, Front) so a re-run edits instead of duplicating, mirroring the
|
|
stable-guid behaviour of the .apkg path; `replace` also deletes dropped notes.
|
|
`sync()` pushes to AnkiWeb, which is what gets the cards onto AnkiDroid at its
|
|
next open.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
_LOG = logging.getLogger(__name__)
|
|
|
|
URL = "http://127.0.0.1:8765"
|
|
|
|
|
|
class AnkiConnectError(RuntimeError):
|
|
"""AnkiConnect answered with an error field."""
|
|
|
|
def __init__(self, action: str, error: object) -> None:
|
|
"""Name the action and the error AnkiConnect returned."""
|
|
super().__init__(f"{action}: {error}")
|
|
|
|
|
|
def _call(action: str, **params: object) -> Any:
|
|
"""One AnkiConnect request; its `result`, or AnkiConnectError."""
|
|
body = json.dumps({"action": action, "version": 6, "params": params}).encode()
|
|
req = urllib.request.Request(
|
|
URL, data=body, headers={"Content-Type": "application/json"}
|
|
)
|
|
with urllib.request.urlopen(req, timeout=30) as resp: # fixed localhost URL
|
|
payload = json.load(resp)
|
|
if payload.get("error"):
|
|
raise AnkiConnectError(action, payload["error"])
|
|
return payload.get("result")
|
|
|
|
|
|
def available() -> bool:
|
|
"""Whether Anki desktop is open with AnkiConnect answering."""
|
|
try:
|
|
return _call("version") is not None
|
|
except (
|
|
urllib.error.URLError,
|
|
TimeoutError,
|
|
ConnectionError,
|
|
AnkiConnectError,
|
|
) as exc:
|
|
_LOG.warning("AnkiConnect is not available: %s", exc)
|
|
return False
|
|
|
|
|
|
def ensure_model(name: str, fields: list[str], qfmt: str, afmt: str, css: str) -> None:
|
|
"""Create the note type unless the collection already has it."""
|
|
if name in _call("modelNames"):
|
|
return
|
|
_call(
|
|
"createModel",
|
|
modelName=name,
|
|
inOrderFields=fields,
|
|
css=css,
|
|
cardTemplates=[{"Name": "Card 1", "Front": qfmt, "Back": afmt}],
|
|
)
|
|
|
|
|
|
def _term(field: str, value: str) -> str:
|
|
"""Quote the WHOLE `field:value` term; quotes after the colon match nothing."""
|
|
return '"' + field + ":" + value.replace('"', '\\"') + '"'
|
|
|
|
|
|
def _existing_by_front(deck: str, slug: str) -> dict[str, int]:
|
|
r"""Map unescaped Front -> note id for every note of this problem.
|
|
|
|
Diffing in Python, not via a Front search: Anki search treats `_ * \`
|
|
specially and fields are stored HTML-escaped, so a search misses notes.
|
|
"""
|
|
ids = _call("findNotes", query=f"{_term('deck', deck)} {_term('Problem', slug)}")
|
|
infos = _call("notesInfo", notes=ids) if ids else []
|
|
return {html.unescape(n["fields"]["Front"]["value"]): n["noteId"] for n in infos}
|
|
|
|
|
|
def _add(deck: str, model: str, slug: str, front: str, back: str) -> None:
|
|
"""Add one note and make sure it sits in `deck`."""
|
|
note_id = _call(
|
|
"addNote",
|
|
note={
|
|
"deckName": deck,
|
|
"modelName": model,
|
|
"fields": {"Front": front, "Back": back, "Problem": slug},
|
|
"tags": ["leetcode", slug],
|
|
"options": {"allowDuplicate": False, "duplicateScope": "deck"},
|
|
},
|
|
)
|
|
# With Anki's "add to current deck" preference on, addNote lands in the
|
|
# current deck regardless of deckName (seen on 25.09.2). Move explicitly.
|
|
_call("changeDeck", cards=_call("findCards", query=f"nid:{note_id}"), deck=deck)
|
|
|
|
|
|
def sync_notes(
|
|
deck: str, model: str, slug: str, cards: list[dict[str, str]], *, replace: bool
|
|
) -> tuple[int, int, int]:
|
|
"""Add or update one note per card (fields already HTML-escaped).
|
|
|
|
With `replace`, notes of this problem whose Front is not in `cards` are
|
|
deleted, so a rewritten deck does not keep the cards it dropped.
|
|
Returns (added, updated, deleted).
|
|
"""
|
|
_call("createDeck", deck=deck)
|
|
existing = _existing_by_front(deck, slug)
|
|
added = updated = 0
|
|
keep: set[int] = set()
|
|
for c in cards:
|
|
front, back = c["front"], c["back"]
|
|
note_id = existing.get(html.unescape(front))
|
|
if note_id is not None:
|
|
_call(
|
|
"updateNoteFields",
|
|
note={"id": note_id, "fields": {"Front": front, "Back": back}},
|
|
)
|
|
keep.add(note_id)
|
|
updated += 1
|
|
continue
|
|
_add(deck, model, slug, front, back)
|
|
added += 1
|
|
stale = [i for i in existing.values() if i not in keep] if replace else []
|
|
if stale:
|
|
_call("deleteNotes", notes=stale)
|
|
return added, updated, len(stale)
|
|
|
|
|
|
def sync() -> None:
|
|
"""Ask Anki to sync with AnkiWeb."""
|
|
_call("sync")
|