spacedrep 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. spacedrep-0.1.0/.github/workflows/publish.yml +41 -0
  2. spacedrep-0.1.0/.gitignore +16 -0
  3. spacedrep-0.1.0/.pre-commit-config.yaml +14 -0
  4. spacedrep-0.1.0/LICENSE +21 -0
  5. spacedrep-0.1.0/PKG-INFO +156 -0
  6. spacedrep-0.1.0/README.md +118 -0
  7. spacedrep-0.1.0/pyproject.toml +67 -0
  8. spacedrep-0.1.0/src/spacedrep/__init__.py +1 -0
  9. spacedrep-0.1.0/src/spacedrep/apkg_reader.py +195 -0
  10. spacedrep-0.1.0/src/spacedrep/apkg_writer.py +89 -0
  11. spacedrep-0.1.0/src/spacedrep/cli.py +86 -0
  12. spacedrep-0.1.0/src/spacedrep/commands/__init__.py +0 -0
  13. spacedrep-0.1.0/src/spacedrep/commands/card.py +284 -0
  14. spacedrep-0.1.0/src/spacedrep/commands/db_cmd.py +28 -0
  15. spacedrep-0.1.0/src/spacedrep/commands/deck.py +86 -0
  16. spacedrep-0.1.0/src/spacedrep/commands/fsrs_cmd.py +51 -0
  17. spacedrep-0.1.0/src/spacedrep/commands/review.py +85 -0
  18. spacedrep-0.1.0/src/spacedrep/commands/stats.py +62 -0
  19. spacedrep-0.1.0/src/spacedrep/core.py +714 -0
  20. spacedrep-0.1.0/src/spacedrep/db.py +782 -0
  21. spacedrep-0.1.0/src/spacedrep/fsrs_engine.py +93 -0
  22. spacedrep-0.1.0/src/spacedrep/mcp_server.py +425 -0
  23. spacedrep-0.1.0/src/spacedrep/models.py +204 -0
  24. spacedrep-0.1.0/tests/__init__.py +0 -0
  25. spacedrep-0.1.0/tests/conftest.py +123 -0
  26. spacedrep-0.1.0/tests/test_apkg_reader.py +57 -0
  27. spacedrep-0.1.0/tests/test_apkg_writer.py +61 -0
  28. spacedrep-0.1.0/tests/test_cli.py +647 -0
  29. spacedrep-0.1.0/tests/test_core.py +411 -0
  30. spacedrep-0.1.0/tests/test_db.py +369 -0
  31. spacedrep-0.1.0/tests/test_fsrs_engine.py +126 -0
  32. spacedrep-0.1.0/tests/test_mcp_server.py +539 -0
  33. spacedrep-0.1.0/uv.lock +1586 -0
@@ -0,0 +1,41 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - name: Install uv
15
+ uses: astral-sh/setup-uv@v5
16
+
17
+ - name: Build package
18
+ run: uv build
19
+
20
+ - name: Upload dist artifacts
21
+ uses: actions/upload-artifact@v4
22
+ with:
23
+ name: dist
24
+ path: dist/
25
+
26
+ publish:
27
+ needs: build
28
+ runs-on: ubuntu-latest
29
+ permissions:
30
+ id-token: write
31
+ steps:
32
+ - name: Download dist artifacts
33
+ uses: actions/download-artifact@v4
34
+ with:
35
+ name: dist
36
+ path: dist/
37
+
38
+ - name: Publish to PyPI
39
+ uses: pypa/gh-action-pypi-publish@release/v1
40
+ with:
41
+ verbose: true
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .venv/
5
+ dist/
6
+ *.egg-info/
7
+ .ruff_cache/
8
+ .pyright/
9
+ .pytest_cache/
10
+ reviews.db
11
+ *.apkg
12
+ plans/
13
+ designs/
14
+ .claude/
15
+ .mcp.json
16
+ sqlite_mcp_server.db
@@ -0,0 +1,14 @@
1
+ repos:
2
+ - repo: local
3
+ hooks:
4
+ - id: gitleaks
5
+ name: gitleaks
6
+ entry: gitleaks git --pre-commit --redact --verbose
7
+ language: system
8
+ pass_filenames: false
9
+ - repo: https://github.com/astral-sh/ruff-pre-commit
10
+ rev: v0.11.12
11
+ hooks:
12
+ - id: ruff
13
+ args: [--fix]
14
+ - id: ruff-format
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wpwilson10
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: spacedrep
3
+ Version: 0.1.0
4
+ Summary: Agent-first flashcard CLI with FSRS scheduling and .apkg support
5
+ Project-URL: Homepage, https://github.com/wpwilson10/spacedrep
6
+ Project-URL: Repository, https://github.com/wpwilson10/spacedrep
7
+ Project-URL: Issues, https://github.com/wpwilson10/spacedrep/issues
8
+ Author-email: wpwilson10 <wpwilson10@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: anki,cli,flashcards,fsrs,mcp,spaced-repetition
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Education
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: beautifulsoup4>=4.12
23
+ Requires-Dist: fsrs<7,>=6.3
24
+ Requires-Dist: genanki>=0.13
25
+ Requires-Dist: pydantic>=2.0
26
+ Requires-Dist: typer>=0.15
27
+ Provides-Extra: dev
28
+ Requires-Dist: mcp>=1.0; extra == 'dev'
29
+ Requires-Dist: pre-commit>=4.0; extra == 'dev'
30
+ Requires-Dist: pyright>=1.1; extra == 'dev'
31
+ Requires-Dist: pytest>=8.0; extra == 'dev'
32
+ Requires-Dist: ruff>=0.11; extra == 'dev'
33
+ Provides-Extra: mcp
34
+ Requires-Dist: mcp>=1.0; extra == 'mcp'
35
+ Provides-Extra: optimizer
36
+ Requires-Dist: fsrs[optimizer]; extra == 'optimizer'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # spacedrep
40
+
41
+ [![PyPI](https://img.shields.io/pypi/v/spacedrep)](https://pypi.org/project/spacedrep/)
42
+ [![Python](https://img.shields.io/pypi/pyversions/spacedrep)](https://pypi.org/project/spacedrep/)
43
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
44
+
45
+ Agent-first flashcard CLI with FSRS scheduling and .apkg support.
46
+
47
+ A standalone spaced repetition tool that AI coding agents can drive — no Anki desktop required. Import/export .apkg files, schedule reviews with FSRS, manage cards via JSON-native CLI or MCP server.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install spacedrep
53
+
54
+ # With MCP server support
55
+ pip install spacedrep[mcp]
56
+
57
+ # With FSRS optimizer (requires torch)
58
+ pip install spacedrep[optimizer]
59
+ ```
60
+
61
+ ## Quick Start
62
+
63
+ ```bash
64
+ # Initialize database
65
+ spacedrep db init
66
+
67
+ # Add a card
68
+ spacedrep card add "What is CAP theorem?" "Pick 2 of 3: consistency, availability, partition tolerance" --deck AWS
69
+
70
+ # Bulk add from JSON
71
+ echo '[{"question":"Q1","answer":"A1","deck":"AWS"}]' | spacedrep card add-bulk
72
+
73
+ # Study: get next due card, preview outcomes, submit rating
74
+ spacedrep card next
75
+ spacedrep review preview 1
76
+ spacedrep review submit 1 good
77
+
78
+ # Check what's due
79
+ spacedrep stats due
80
+
81
+ # Import/export Anki decks
82
+ spacedrep deck import ~/Downloads/deck.apkg
83
+ spacedrep deck export ./export.apkg --deck AWS
84
+
85
+ # Pipe card IDs for scripting
86
+ spacedrep card list -q | xargs -I{} spacedrep card get {}
87
+
88
+ # Preview destructive operations
89
+ spacedrep card delete 42 --dry-run
90
+ ```
91
+
92
+ Run `spacedrep --help` for all commands and options.
93
+
94
+ ## Agent-First Design
95
+
96
+ - **JSON to stdout, errors to stderr** — stdout is the API contract
97
+ - **Meaningful exit codes** — 0=success, 2=usage error, 3=not found
98
+ - **Idempotent** — imports dedup, adds are safe to retry
99
+ - **Non-interactive** — no prompts, no confirmation dialogs
100
+ - **`--quiet` mode** — bare values for piping into `xargs` or `while read`
101
+ - **`--dry-run`** — preview destructive operations without side effects
102
+
103
+ ## How It Works
104
+
105
+ - **FSRS scheduling** — the same algorithm built into Anki since v23.10, via [py-fsrs](https://github.com/open-spaced-repetition/py-fsrs)
106
+ - **Leech detection** — cards rated "again" 8+ times while in Review/Relearning are auto-suspended
107
+ - **Review preview** — see what each rating would produce before committing
108
+ - **Parameter optimization** — personalize FSRS from your review history (`pip install spacedrep[optimizer]`)
109
+ - **SQLite storage** — single file, SQL-queryable review history
110
+ - **.apkg compatible** — import from and export to Anki
111
+
112
+ ## MCP Server
113
+
114
+ An MCP server exposes all 20 spacedrep operations as tools for AI agents (Claude Code, Claude Desktop, etc.) over the MCP protocol.
115
+
116
+ ```bash
117
+ pip install spacedrep[mcp]
118
+ ```
119
+
120
+ **Claude Code:**
121
+ ```bash
122
+ claude mcp add spacedrep -e SPACEDREP_DB=/path/to/reviews.db -- uv run --directory /path/to/spacedrep spacedrep-mcp
123
+ ```
124
+
125
+ **Claude Desktop** (`claude_desktop_config.json`):
126
+ ```json
127
+ {
128
+ "mcpServers": {
129
+ "spacedrep": {
130
+ "command": "uv",
131
+ "args": ["run", "--directory", "/path/to/spacedrep", "spacedrep-mcp"],
132
+ "env": {
133
+ "SPACEDREP_DB": "/path/to/reviews.db"
134
+ }
135
+ }
136
+ }
137
+ }
138
+ ```
139
+
140
+ Set `SPACEDREP_DB` to configure the database path (default: `./reviews.db`).
141
+
142
+ ## Development
143
+
144
+ ```bash
145
+ git clone https://github.com/wpwilson10/spacedrep.git && cd spacedrep
146
+ uv venv && uv sync --all-extras
147
+ pre-commit install
148
+
149
+ uv run pytest # Test
150
+ uv run pyright . # Type check
151
+ uv run ruff check . # Lint
152
+ ```
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,118 @@
1
+ # spacedrep
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/spacedrep)](https://pypi.org/project/spacedrep/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/spacedrep)](https://pypi.org/project/spacedrep/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ Agent-first flashcard CLI with FSRS scheduling and .apkg support.
8
+
9
+ A standalone spaced repetition tool that AI coding agents can drive — no Anki desktop required. Import/export .apkg files, schedule reviews with FSRS, manage cards via JSON-native CLI or MCP server.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install spacedrep
15
+
16
+ # With MCP server support
17
+ pip install spacedrep[mcp]
18
+
19
+ # With FSRS optimizer (requires torch)
20
+ pip install spacedrep[optimizer]
21
+ ```
22
+
23
+ ## Quick Start
24
+
25
+ ```bash
26
+ # Initialize database
27
+ spacedrep db init
28
+
29
+ # Add a card
30
+ spacedrep card add "What is CAP theorem?" "Pick 2 of 3: consistency, availability, partition tolerance" --deck AWS
31
+
32
+ # Bulk add from JSON
33
+ echo '[{"question":"Q1","answer":"A1","deck":"AWS"}]' | spacedrep card add-bulk
34
+
35
+ # Study: get next due card, preview outcomes, submit rating
36
+ spacedrep card next
37
+ spacedrep review preview 1
38
+ spacedrep review submit 1 good
39
+
40
+ # Check what's due
41
+ spacedrep stats due
42
+
43
+ # Import/export Anki decks
44
+ spacedrep deck import ~/Downloads/deck.apkg
45
+ spacedrep deck export ./export.apkg --deck AWS
46
+
47
+ # Pipe card IDs for scripting
48
+ spacedrep card list -q | xargs -I{} spacedrep card get {}
49
+
50
+ # Preview destructive operations
51
+ spacedrep card delete 42 --dry-run
52
+ ```
53
+
54
+ Run `spacedrep --help` for all commands and options.
55
+
56
+ ## Agent-First Design
57
+
58
+ - **JSON to stdout, errors to stderr** — stdout is the API contract
59
+ - **Meaningful exit codes** — 0=success, 2=usage error, 3=not found
60
+ - **Idempotent** — imports dedup, adds are safe to retry
61
+ - **Non-interactive** — no prompts, no confirmation dialogs
62
+ - **`--quiet` mode** — bare values for piping into `xargs` or `while read`
63
+ - **`--dry-run`** — preview destructive operations without side effects
64
+
65
+ ## How It Works
66
+
67
+ - **FSRS scheduling** — the same algorithm built into Anki since v23.10, via [py-fsrs](https://github.com/open-spaced-repetition/py-fsrs)
68
+ - **Leech detection** — cards rated "again" 8+ times while in Review/Relearning are auto-suspended
69
+ - **Review preview** — see what each rating would produce before committing
70
+ - **Parameter optimization** — personalize FSRS from your review history (`pip install spacedrep[optimizer]`)
71
+ - **SQLite storage** — single file, SQL-queryable review history
72
+ - **.apkg compatible** — import from and export to Anki
73
+
74
+ ## MCP Server
75
+
76
+ An MCP server exposes all 20 spacedrep operations as tools for AI agents (Claude Code, Claude Desktop, etc.) over the MCP protocol.
77
+
78
+ ```bash
79
+ pip install spacedrep[mcp]
80
+ ```
81
+
82
+ **Claude Code:**
83
+ ```bash
84
+ claude mcp add spacedrep -e SPACEDREP_DB=/path/to/reviews.db -- uv run --directory /path/to/spacedrep spacedrep-mcp
85
+ ```
86
+
87
+ **Claude Desktop** (`claude_desktop_config.json`):
88
+ ```json
89
+ {
90
+ "mcpServers": {
91
+ "spacedrep": {
92
+ "command": "uv",
93
+ "args": ["run", "--directory", "/path/to/spacedrep", "spacedrep-mcp"],
94
+ "env": {
95
+ "SPACEDREP_DB": "/path/to/reviews.db"
96
+ }
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ Set `SPACEDREP_DB` to configure the database path (default: `./reviews.db`).
103
+
104
+ ## Development
105
+
106
+ ```bash
107
+ git clone https://github.com/wpwilson10/spacedrep.git && cd spacedrep
108
+ uv venv && uv sync --all-extras
109
+ pre-commit install
110
+
111
+ uv run pytest # Test
112
+ uv run pyright . # Type check
113
+ uv run ruff check . # Lint
114
+ ```
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,67 @@
1
+ [project]
2
+ name = "spacedrep"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.12"
5
+ description = "Agent-first flashcard CLI with FSRS scheduling and .apkg support"
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ authors = [{ name = "wpwilson10", email = "wpwilson10@gmail.com" }]
9
+ keywords = ["flashcards", "spaced-repetition", "fsrs", "anki", "mcp", "cli"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Topic :: Education",
20
+ ]
21
+ dependencies = [
22
+ "typer>=0.15",
23
+ "fsrs>=6.3,<7",
24
+ "genanki>=0.13",
25
+ "pydantic>=2.0",
26
+ "beautifulsoup4>=4.12",
27
+ ]
28
+
29
+ [project.scripts]
30
+ spacedrep = "spacedrep.cli:app"
31
+ spacedrep-mcp = "spacedrep.mcp_server:main"
32
+
33
+ [project.optional-dependencies]
34
+ mcp = ["mcp>=1.0"]
35
+ optimizer = ["fsrs[optimizer]"]
36
+ dev = ["pytest>=8.0", "pyright>=1.1", "ruff>=0.11", "pre-commit>=4.0", "mcp>=1.0"]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/wpwilson10/spacedrep"
40
+ Repository = "https://github.com/wpwilson10/spacedrep"
41
+ Issues = "https://github.com/wpwilson10/spacedrep/issues"
42
+
43
+ [build-system]
44
+ requires = ["hatchling"]
45
+ build-backend = "hatchling.build"
46
+
47
+ [tool.pyright]
48
+ pythonVersion = "3.12"
49
+ typeCheckingMode = "strict"
50
+ reportMissingTypeStubs = false
51
+
52
+ [[tool.pyright.executionEnvironments]]
53
+ root = "tests/test_mcp_server.py"
54
+ reportUnknownVariableType = false
55
+ reportUnknownMemberType = false
56
+ reportAttributeAccessIssue = false
57
+
58
+ [tool.ruff]
59
+ target-version = "py312"
60
+ line-length = 100
61
+
62
+ [tool.ruff.lint]
63
+ select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM", "TCH"]
64
+
65
+ [tool.ruff.lint.per-file-ignores]
66
+ "src/spacedrep/commands/*.py" = ["B008"]
67
+ "src/spacedrep/cli.py" = ["E402"]
@@ -0,0 +1 @@
1
+ """Agent-first flashcard CLI with FSRS scheduling and .apkg support."""
@@ -0,0 +1,195 @@
1
+ """Parse .apkg files (ZIP'd SQLite) into card records."""
2
+
3
+ import json
4
+ import sqlite3
5
+ import tempfile
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ from bs4 import BeautifulSoup
10
+
11
+ from spacedrep.models import CardRecord, DeckRecord
12
+
13
+ QUESTION_FIELD_NAMES = {"front", "question", "prompt", "q"}
14
+ ANSWER_FIELD_NAMES = {"back", "answer", "implementation", "a", "response"}
15
+
16
+
17
+ def read_apkg(
18
+ apkg_path: Path,
19
+ question_field: str | None = None,
20
+ answer_field: str | None = None,
21
+ ) -> tuple[list[DeckRecord], list[CardRecord], dict[str, list[str] | str], dict[int, str]]:
22
+ """Read an .apkg file and return (decks, cards, field_info, note_deck_map).
23
+
24
+ field_info contains: fields (list of field names), question_field, answer_field.
25
+ note_deck_map maps source_note_id to deck name.
26
+ """
27
+ with tempfile.TemporaryDirectory() as tmpdir:
28
+ tmppath = Path(tmpdir)
29
+ with zipfile.ZipFile(apkg_path, "r") as zf:
30
+ for member in zf.namelist():
31
+ if member.startswith("/") or ".." in member:
32
+ msg = f"Refusing to extract potentially unsafe path: {member}"
33
+ raise ValueError(msg)
34
+ zf.extractall(tmppath)
35
+
36
+ # Find the SQLite database
37
+ db_file = tmppath / "collection.anki21"
38
+ if not db_file.exists():
39
+ db_file = tmppath / "collection.anki2"
40
+ if not db_file.exists():
41
+ msg = f"No collection database found in {apkg_path}"
42
+ raise ValueError(msg)
43
+
44
+ conn = sqlite3.connect(str(db_file))
45
+ conn.row_factory = sqlite3.Row
46
+
47
+ try:
48
+ # Parse models and decks from col table
49
+ col_row = conn.execute("SELECT models, decks FROM col").fetchone()
50
+ models_json = json.loads(col_row["models"])
51
+ decks_json = json.loads(col_row["decks"])
52
+
53
+ # Build model field mapping: {model_id: [field_names]}
54
+ model_fields: dict[str, list[str]] = {}
55
+ for mid, model in models_json.items():
56
+ model_fields[mid] = [f["name"] for f in model["flds"]]
57
+
58
+ # Build deck name mapping: {deck_id: deck_name}
59
+ deck_names: dict[str, str] = {}
60
+ for did, deck_data in decks_json.items():
61
+ deck_names[did] = deck_data["name"]
62
+
63
+ # Get card-to-deck mapping
64
+ card_deck_map: dict[int, str] = {}
65
+ for row in conn.execute("SELECT nid, did FROM cards").fetchall():
66
+ card_deck_map[row["nid"]] = str(row["did"])
67
+
68
+ # Process notes
69
+ notes = conn.execute("SELECT id, mid, flds, guid, tags FROM notes").fetchall()
70
+
71
+ all_field_names: list[str] = []
72
+ q_field = ""
73
+ a_field = ""
74
+ decks: list[DeckRecord] = []
75
+ cards: list[CardRecord] = []
76
+ note_deck_map: dict[int, str] = {}
77
+ seen_decks: set[str] = set()
78
+
79
+ for note in notes:
80
+ mid = str(note["mid"])
81
+ fields = note["flds"].split("\x1f")
82
+ field_names = model_fields.get(mid, [])
83
+
84
+ if not all_field_names and field_names:
85
+ all_field_names = field_names
86
+ qi, ai = detect_field_mapping(field_names, question_field, answer_field)
87
+ q_field = field_names[qi]
88
+ a_field = field_names[ai]
89
+
90
+ qi, ai = detect_field_mapping(field_names, question_field, answer_field)
91
+
92
+ question = strip_html(fields[qi]) if qi < len(fields) else ""
93
+ answer_text = strip_html(fields[ai]) if ai < len(fields) else ""
94
+
95
+ # Build extra_fields from remaining fields
96
+ extra: dict[str, str] = {}
97
+ for i, fname in enumerate(field_names):
98
+ if i != qi and i != ai and i < len(fields):
99
+ stripped = strip_html(fields[i])
100
+ if stripped:
101
+ extra[fname] = stripped
102
+
103
+ # Find deck for this note
104
+ did_str = card_deck_map.get(note["id"], "1")
105
+ deck_name = deck_names.get(did_str, "Default")
106
+
107
+ if deck_name not in seen_decks:
108
+ seen_decks.add(deck_name)
109
+ decks.append(
110
+ DeckRecord(
111
+ name=deck_name,
112
+ source_id=int(did_str) if did_str.isdigit() else None,
113
+ )
114
+ )
115
+
116
+ note_deck_map[note["id"]] = deck_name
117
+ raw_tags = note["tags"].strip() if note["tags"] else ""
118
+ tags = ",".join(raw_tags.split()) if raw_tags else ""
119
+
120
+ cards.append(
121
+ CardRecord(
122
+ deck_id=0, # will be set during import
123
+ question=question,
124
+ answer=answer_text,
125
+ extra_fields=extra,
126
+ tags=tags,
127
+ source="apkg",
128
+ source_note_id=note["id"],
129
+ source_note_guid=note["guid"],
130
+ )
131
+ )
132
+
133
+ field_info: dict[str, list[str] | str] = {
134
+ "fields": all_field_names,
135
+ "question_field": q_field,
136
+ "answer_field": a_field,
137
+ }
138
+ return decks, cards, field_info, note_deck_map
139
+
140
+ finally:
141
+ conn.close()
142
+
143
+
144
+ def detect_field_mapping(
145
+ field_names: list[str],
146
+ question_field: str | None,
147
+ answer_field: str | None,
148
+ ) -> tuple[int, int]:
149
+ """Returns (question_index, answer_index).
150
+
151
+ Priority: explicit params > name matching > positional (0, 1).
152
+ """
153
+ names_lower = [n.lower() for n in field_names]
154
+
155
+ # Question field
156
+ if question_field:
157
+ try:
158
+ qi = names_lower.index(question_field.lower())
159
+ except ValueError:
160
+ msg = f"Question field '{question_field}' not found. Available: {field_names}"
161
+ raise ValueError(msg) from None
162
+ else:
163
+ qi = find_field_index(names_lower, QUESTION_FIELD_NAMES)
164
+ if qi is None:
165
+ qi = 0
166
+
167
+ # Answer field
168
+ if answer_field:
169
+ try:
170
+ ai = names_lower.index(answer_field.lower())
171
+ except ValueError:
172
+ msg = f"Answer field '{answer_field}' not found. Available: {field_names}"
173
+ raise ValueError(msg) from None
174
+ else:
175
+ ai = find_field_index(names_lower, ANSWER_FIELD_NAMES)
176
+ if ai is None:
177
+ ai = 1 if len(field_names) > 1 else 0
178
+
179
+ return qi, ai
180
+
181
+
182
+ def find_field_index(names_lower: list[str], candidates: set[str]) -> int | None:
183
+ """Find the first field name that matches a candidate set."""
184
+ for i, name in enumerate(names_lower):
185
+ if name in candidates:
186
+ return i
187
+ return None
188
+
189
+
190
+ def strip_html(html: str) -> str:
191
+ """Strip HTML tags, returning plain text."""
192
+ if not html or "<" not in html:
193
+ return html.strip()
194
+ soup = BeautifulSoup(html, "html.parser")
195
+ return soup.get_text(separator=" ", strip=True)