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.
- spacedrep-0.1.0/.github/workflows/publish.yml +41 -0
- spacedrep-0.1.0/.gitignore +16 -0
- spacedrep-0.1.0/.pre-commit-config.yaml +14 -0
- spacedrep-0.1.0/LICENSE +21 -0
- spacedrep-0.1.0/PKG-INFO +156 -0
- spacedrep-0.1.0/README.md +118 -0
- spacedrep-0.1.0/pyproject.toml +67 -0
- spacedrep-0.1.0/src/spacedrep/__init__.py +1 -0
- spacedrep-0.1.0/src/spacedrep/apkg_reader.py +195 -0
- spacedrep-0.1.0/src/spacedrep/apkg_writer.py +89 -0
- spacedrep-0.1.0/src/spacedrep/cli.py +86 -0
- spacedrep-0.1.0/src/spacedrep/commands/__init__.py +0 -0
- spacedrep-0.1.0/src/spacedrep/commands/card.py +284 -0
- spacedrep-0.1.0/src/spacedrep/commands/db_cmd.py +28 -0
- spacedrep-0.1.0/src/spacedrep/commands/deck.py +86 -0
- spacedrep-0.1.0/src/spacedrep/commands/fsrs_cmd.py +51 -0
- spacedrep-0.1.0/src/spacedrep/commands/review.py +85 -0
- spacedrep-0.1.0/src/spacedrep/commands/stats.py +62 -0
- spacedrep-0.1.0/src/spacedrep/core.py +714 -0
- spacedrep-0.1.0/src/spacedrep/db.py +782 -0
- spacedrep-0.1.0/src/spacedrep/fsrs_engine.py +93 -0
- spacedrep-0.1.0/src/spacedrep/mcp_server.py +425 -0
- spacedrep-0.1.0/src/spacedrep/models.py +204 -0
- spacedrep-0.1.0/tests/__init__.py +0 -0
- spacedrep-0.1.0/tests/conftest.py +123 -0
- spacedrep-0.1.0/tests/test_apkg_reader.py +57 -0
- spacedrep-0.1.0/tests/test_apkg_writer.py +61 -0
- spacedrep-0.1.0/tests/test_cli.py +647 -0
- spacedrep-0.1.0/tests/test_core.py +411 -0
- spacedrep-0.1.0/tests/test_db.py +369 -0
- spacedrep-0.1.0/tests/test_fsrs_engine.py +126 -0
- spacedrep-0.1.0/tests/test_mcp_server.py +539 -0
- 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,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
|
spacedrep-0.1.0/LICENSE
ADDED
|
@@ -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.
|
spacedrep-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/spacedrep/)
|
|
42
|
+
[](https://pypi.org/project/spacedrep/)
|
|
43
|
+
[](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
|
+
[](https://pypi.org/project/spacedrep/)
|
|
4
|
+
[](https://pypi.org/project/spacedrep/)
|
|
5
|
+
[](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)
|