ankitextlayer 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.
- ankitextlayer-0.1.0/PKG-INFO +132 -0
- ankitextlayer-0.1.0/README.md +110 -0
- ankitextlayer-0.1.0/ankitextlayer/__init__.py +6 -0
- ankitextlayer-0.1.0/ankitextlayer/anki_client.py +32 -0
- ankitextlayer-0.1.0/ankitextlayer/anki_to_markdown.py +350 -0
- ankitextlayer-0.1.0/ankitextlayer/cli.py +247 -0
- ankitextlayer-0.1.0/ankitextlayer/config.py +116 -0
- ankitextlayer-0.1.0/ankitextlayer/data/AnkiTextLayer.md +102 -0
- ankitextlayer-0.1.0/ankitextlayer/data/__init__.py +1 -0
- ankitextlayer-0.1.0/ankitextlayer/ensure_models.py +99 -0
- ankitextlayer-0.1.0/ankitextlayer/git.py +63 -0
- ankitextlayer-0.1.0/ankitextlayer/html_converter.py +321 -0
- ankitextlayer-0.1.0/ankitextlayer/init.py +173 -0
- ankitextlayer-0.1.0/ankitextlayer/log.py +161 -0
- ankitextlayer-0.1.0/ankitextlayer/markdown_converter.py +311 -0
- ankitextlayer-0.1.0/ankitextlayer/markdown_helpers.py +209 -0
- ankitextlayer-0.1.0/ankitextlayer/markdown_to_anki.py +839 -0
- ankitextlayer-0.1.0/ankitextlayer/models/LayerClozeBack.template.anki +14 -0
- ankitextlayer-0.1.0/ankitextlayer/models/LayerClozeFront.template.anki +7 -0
- ankitextlayer-0.1.0/ankitextlayer/models/LayerQABack.template.anki +16 -0
- ankitextlayer-0.1.0/ankitextlayer/models/LayerQAFront.template.anki +7 -0
- ankitextlayer-0.1.0/ankitextlayer/models/Styling.css +169 -0
- ankitextlayer-0.1.0/ankitextlayer/models/__init__.py +1 -0
- ankitextlayer-0.1.0/ankitextlayer.egg-info/PKG-INFO +132 -0
- ankitextlayer-0.1.0/ankitextlayer.egg-info/SOURCES.txt +33 -0
- ankitextlayer-0.1.0/ankitextlayer.egg-info/dependency_links.txt +1 -0
- ankitextlayer-0.1.0/ankitextlayer.egg-info/entry_points.txt +2 -0
- ankitextlayer-0.1.0/ankitextlayer.egg-info/requires.txt +6 -0
- ankitextlayer-0.1.0/ankitextlayer.egg-info/top_level.txt +1 -0
- ankitextlayer-0.1.0/pyproject.toml +50 -0
- ankitextlayer-0.1.0/setup.cfg +4 -0
- ankitextlayer-0.1.0/tests/test_cloze.py +247 -0
- ankitextlayer-0.1.0/tests/test_html_converter.py +214 -0
- ankitextlayer-0.1.0/tests/test_list_conversion.py +53 -0
- ankitextlayer-0.1.0/tests/test_markdown_converter.py +269 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ankitextlayer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bidirectional sync between Anki and Markdown
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Development Status :: 4 - Beta
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Topic :: Education
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: requests>=2.28.0
|
|
18
|
+
Requires-Dist: beautifulsoup4>=4.11.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# AnkiTextLayer (ATL)
|
|
24
|
+
|
|
25
|
+
Write your decks in Markdown, sync them to Anki for studying, make edits in either place, and sync changes back.
|
|
26
|
+
|
|
27
|
+
## Why Use This
|
|
28
|
+
|
|
29
|
+
- **Write in Markdown**: Your cards live in plain text: easy to edit fast, search, refactor, and review in your editor.
|
|
30
|
+
- **Keep full overview**: Browse hundreds of cards in one file, search across decks instantly, and reorganize freely.
|
|
31
|
+
- **Work with AI**: Markdown is a great interface for AI help (drafting, rephrasing, generating variants) while you stay in control of the final wording.
|
|
32
|
+
- **Version control**: Track exactly what changed and when, roll back safely, and collaborate using Git like any other text-based project.
|
|
33
|
+
|
|
34
|
+
If you already love Anki for studying, ATL just upgrades how you create and maintain your cards.
|
|
35
|
+
|
|
36
|
+
## How It Works
|
|
37
|
+
|
|
38
|
+
In a directory of your choice, each Markdown file represents an Anki deck. On first sync, ATL assigns HTML comment IDs to each card for tracking. After that, it knows what's new, changed, moved between decks, or deleted, and will sync accordingly.
|
|
39
|
+
|
|
40
|
+
Two note types are provided: `LayerQA` and `LayerCloze`. In a markdown file, each field is represented by a line starting with a specific letter and a colon. `Q:` and `A:` start question and answer fields, `T:` starts a text field for clozes, and `E:` and `M:` start optional extra and "more" fields on the back of the card. Cards are separated by a blank line, three dashes, and another blank line.
|
|
41
|
+
|
|
42
|
+
A fresh markdown deck could look like this:
|
|
43
|
+
|
|
44
|
+
```markdown
|
|
45
|
+
Q: What is the capital of France?
|
|
46
|
+
A: Paris.
|
|
47
|
+
E: {width=700}
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
T: The capital of Germany is {{c1::Berlin}}.
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
With the first ATL sync to Anki, the deck and each card gets a unique ID in an HTML comment:
|
|
55
|
+
|
|
56
|
+
```markdown
|
|
57
|
+
<!-- deck_id: 1770487991521 -->
|
|
58
|
+
<!-- card_id: 1770487991522 -->
|
|
59
|
+
Q: What is the capital of France?
|
|
60
|
+
A: Paris.
|
|
61
|
+
E: {width=700}
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
<!-- note_id: 1770487991521 -->
|
|
66
|
+
T: The capital of Germany is {{c1::Berlin}}.
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
ATL only acts on the provided note types, so your existing collection is never affected. You can mix managed and unmanaged cards in the same deck without issues.
|
|
70
|
+
|
|
71
|
+
Before every ATL sync, it automatically creates a Git commit so you can always roll back if something breaks.
|
|
72
|
+
|
|
73
|
+
## How to Get Started
|
|
74
|
+
|
|
75
|
+
Install via [pipx](https://github.com/pypa/pipx):
|
|
76
|
+
```bash
|
|
77
|
+
pipx install ankitextlayer
|
|
78
|
+
```
|
|
79
|
+
Make sure Anki is running, with the [AnkiConnect add-on](https://ankiweb.net/shared/info/2055492159) enabled. Then initialize ATL in any directory (not your Anki data folder):
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
ankitextlayer init --tutorial
|
|
83
|
+
```
|
|
84
|
+
The tutorial flag creates a sample markdown deck with further information. We can import it into Anki using:
|
|
85
|
+
```bash
|
|
86
|
+
ankitextlayer ma
|
|
87
|
+
```
|
|
88
|
+
Where `ma` is shorthand for `markdown-to-anki`. Conversely, there is `ankitextlayer am` for `anki-to-markdown`. The tutorial deck should provide you with a good starting point to explore the features of ATL.
|
|
89
|
+
|
|
90
|
+
## Workflow
|
|
91
|
+
|
|
92
|
+
Start by writing your cards in Markdown. Images can be added by pasting them directly into your markdown file in VS Code as explained in the tutorial. When you ATL sync to Anki, the images are saved in the media collection of Anki (`media/LayerMedia/`) via a symlink set up by ATL.
|
|
93
|
+
|
|
94
|
+
```markdown
|
|
95
|
+
Q: Question text here
|
|
96
|
+
A: Answer text here
|
|
97
|
+
E: Extra information (optional)
|
|
98
|
+
M: Content behind "more" button (optional)
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
T: Text with {{c1::cloze deletions}}.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
And so on…
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Push your cards to Anki, study them, make edits in either place, then pull changes back:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
ankitextlayer init # Use once per directory, to set up
|
|
113
|
+
ankitextlayer ma # Markdown to Anki
|
|
114
|
+
# Review and edit in Anki...
|
|
115
|
+
ankitextlayer am # Anki to Markdown
|
|
116
|
+
```
|
|
117
|
+
And that's it! Your markdown files and Anki decks stay in sync, giving you the best of both worlds.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Support This Project
|
|
122
|
+
|
|
123
|
+
If ATL saves you time or makes your workflow better, consider buying me a coffee!
|
|
124
|
+
|
|
125
|
+
Your support helps me:
|
|
126
|
+
- Keep the project maintained and bug-free
|
|
127
|
+
- Add new features based on user feedback
|
|
128
|
+
- Respond quickly to issues and questions
|
|
129
|
+
|
|
130
|
+
[](https://ko-fi.com/visserle)
|
|
131
|
+
|
|
132
|
+
MIT License
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# AnkiTextLayer (ATL)
|
|
2
|
+
|
|
3
|
+
Write your decks in Markdown, sync them to Anki for studying, make edits in either place, and sync changes back.
|
|
4
|
+
|
|
5
|
+
## Why Use This
|
|
6
|
+
|
|
7
|
+
- **Write in Markdown**: Your cards live in plain text: easy to edit fast, search, refactor, and review in your editor.
|
|
8
|
+
- **Keep full overview**: Browse hundreds of cards in one file, search across decks instantly, and reorganize freely.
|
|
9
|
+
- **Work with AI**: Markdown is a great interface for AI help (drafting, rephrasing, generating variants) while you stay in control of the final wording.
|
|
10
|
+
- **Version control**: Track exactly what changed and when, roll back safely, and collaborate using Git like any other text-based project.
|
|
11
|
+
|
|
12
|
+
If you already love Anki for studying, ATL just upgrades how you create and maintain your cards.
|
|
13
|
+
|
|
14
|
+
## How It Works
|
|
15
|
+
|
|
16
|
+
In a directory of your choice, each Markdown file represents an Anki deck. On first sync, ATL assigns HTML comment IDs to each card for tracking. After that, it knows what's new, changed, moved between decks, or deleted, and will sync accordingly.
|
|
17
|
+
|
|
18
|
+
Two note types are provided: `LayerQA` and `LayerCloze`. In a markdown file, each field is represented by a line starting with a specific letter and a colon. `Q:` and `A:` start question and answer fields, `T:` starts a text field for clozes, and `E:` and `M:` start optional extra and "more" fields on the back of the card. Cards are separated by a blank line, three dashes, and another blank line.
|
|
19
|
+
|
|
20
|
+
A fresh markdown deck could look like this:
|
|
21
|
+
|
|
22
|
+
```markdown
|
|
23
|
+
Q: What is the capital of France?
|
|
24
|
+
A: Paris.
|
|
25
|
+
E: {width=700}
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
T: The capital of Germany is {{c1::Berlin}}.
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
With the first ATL sync to Anki, the deck and each card gets a unique ID in an HTML comment:
|
|
33
|
+
|
|
34
|
+
```markdown
|
|
35
|
+
<!-- deck_id: 1770487991521 -->
|
|
36
|
+
<!-- card_id: 1770487991522 -->
|
|
37
|
+
Q: What is the capital of France?
|
|
38
|
+
A: Paris.
|
|
39
|
+
E: {width=700}
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
<!-- note_id: 1770487991521 -->
|
|
44
|
+
T: The capital of Germany is {{c1::Berlin}}.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
ATL only acts on the provided note types, so your existing collection is never affected. You can mix managed and unmanaged cards in the same deck without issues.
|
|
48
|
+
|
|
49
|
+
Before every ATL sync, it automatically creates a Git commit so you can always roll back if something breaks.
|
|
50
|
+
|
|
51
|
+
## How to Get Started
|
|
52
|
+
|
|
53
|
+
Install via [pipx](https://github.com/pypa/pipx):
|
|
54
|
+
```bash
|
|
55
|
+
pipx install ankitextlayer
|
|
56
|
+
```
|
|
57
|
+
Make sure Anki is running, with the [AnkiConnect add-on](https://ankiweb.net/shared/info/2055492159) enabled. Then initialize ATL in any directory (not your Anki data folder):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
ankitextlayer init --tutorial
|
|
61
|
+
```
|
|
62
|
+
The tutorial flag creates a sample markdown deck with further information. We can import it into Anki using:
|
|
63
|
+
```bash
|
|
64
|
+
ankitextlayer ma
|
|
65
|
+
```
|
|
66
|
+
Where `ma` is shorthand for `markdown-to-anki`. Conversely, there is `ankitextlayer am` for `anki-to-markdown`. The tutorial deck should provide you with a good starting point to explore the features of ATL.
|
|
67
|
+
|
|
68
|
+
## Workflow
|
|
69
|
+
|
|
70
|
+
Start by writing your cards in Markdown. Images can be added by pasting them directly into your markdown file in VS Code as explained in the tutorial. When you ATL sync to Anki, the images are saved in the media collection of Anki (`media/LayerMedia/`) via a symlink set up by ATL.
|
|
71
|
+
|
|
72
|
+
```markdown
|
|
73
|
+
Q: Question text here
|
|
74
|
+
A: Answer text here
|
|
75
|
+
E: Extra information (optional)
|
|
76
|
+
M: Content behind "more" button (optional)
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
T: Text with {{c1::cloze deletions}}.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
And so on…
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Push your cards to Anki, study them, make edits in either place, then pull changes back:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
ankitextlayer init # Use once per directory, to set up
|
|
91
|
+
ankitextlayer ma # Markdown to Anki
|
|
92
|
+
# Review and edit in Anki...
|
|
93
|
+
ankitextlayer am # Anki to Markdown
|
|
94
|
+
```
|
|
95
|
+
And that's it! Your markdown files and Anki decks stay in sync, giving you the best of both worlds.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Support This Project
|
|
100
|
+
|
|
101
|
+
If ATL saves you time or makes your workflow better, consider buying me a coffee!
|
|
102
|
+
|
|
103
|
+
Your support helps me:
|
|
104
|
+
- Keep the project maintained and bug-free
|
|
105
|
+
- Add new features based on user feedback
|
|
106
|
+
- Respond quickly to issues and questions
|
|
107
|
+
|
|
108
|
+
[](https://ko-fi.com/visserle)
|
|
109
|
+
|
|
110
|
+
MIT License
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shared AnkiConnect client and helpers used by both import/export scripts."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from ankitextlayer.config import ANKI_CONNECT_URL
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def invoke(action: str, **params) -> Any:
|
|
12
|
+
"""Send a request to AnkiConnect and return the result.
|
|
13
|
+
|
|
14
|
+
Raises an Exception when AnkiConnect returns an error.
|
|
15
|
+
"""
|
|
16
|
+
response = requests.post(
|
|
17
|
+
ANKI_CONNECT_URL,
|
|
18
|
+
json={"action": action, "version": 6, "params": params},
|
|
19
|
+
timeout=10,
|
|
20
|
+
)
|
|
21
|
+
result = response.json()
|
|
22
|
+
if result.get("error"):
|
|
23
|
+
raise Exception(f"AnkiConnect error: {result['error']}")
|
|
24
|
+
return result["result"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def extract_deck_id(content: str) -> tuple[int | None, str]:
|
|
28
|
+
"""Extract deck_id from the first line and return (deck_id, remaining content)."""
|
|
29
|
+
match = re.match(r"<!--\s*deck_id:\s*(\d+)\s*-->\n?", content)
|
|
30
|
+
if match:
|
|
31
|
+
return int(match.group(1)), content[match.end() :]
|
|
32
|
+
return None, content
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Transcribe Anki decks to Markdown files."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ankitextlayer.anki_client import extract_deck_id, invoke
|
|
9
|
+
from ankitextlayer.config import CARD_SEPARATOR, SUPPORTED_NOTE_TYPES
|
|
10
|
+
from ankitextlayer.html_converter import HTMLToMarkdown
|
|
11
|
+
from ankitextlayer.markdown_helpers import (
|
|
12
|
+
extract_card_blocks,
|
|
13
|
+
format_card,
|
|
14
|
+
sanitize_filename,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class DeckExportResult:
|
|
22
|
+
"""Result of exporting a single deck."""
|
|
23
|
+
|
|
24
|
+
deck_name: str
|
|
25
|
+
file_path: Path | None
|
|
26
|
+
total_cards: int
|
|
27
|
+
updated: int
|
|
28
|
+
created: int
|
|
29
|
+
deleted: int
|
|
30
|
+
skipped: int
|
|
31
|
+
# Block IDs (e.g. "card_id: 123") that appeared/disappeared
|
|
32
|
+
# compared to the previous file, used for cross-deck move detection.
|
|
33
|
+
created_ids: set[str] | None = None
|
|
34
|
+
deleted_ids: set[str] | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def transcribe_deck(
|
|
38
|
+
deck_name: str, output_dir: str = ".", deck_id: int | None = None
|
|
39
|
+
) -> DeckExportResult:
|
|
40
|
+
"""Transcribe an Anki deck to a Markdown file (excluding subdecks)."""
|
|
41
|
+
converter = HTMLToMarkdown()
|
|
42
|
+
# Collect (note_id, formatted_block) tuples.
|
|
43
|
+
# In Anki the note ID is the creation timestamp in milliseconds.
|
|
44
|
+
blocks_with_ids: list[tuple[int, str]] = []
|
|
45
|
+
|
|
46
|
+
# --- LayerQA: one block per card ---
|
|
47
|
+
qa_query = f'deck:"{deck_name}" -deck:"{deck_name}::*" note:LayerQA'
|
|
48
|
+
qa_card_ids = invoke("findCards", query=qa_query)
|
|
49
|
+
|
|
50
|
+
if qa_card_ids:
|
|
51
|
+
qa_cards_info = invoke("cardsInfo", cards=qa_card_ids)
|
|
52
|
+
qa_note_ids = list({card["note"] for card in qa_cards_info})
|
|
53
|
+
qa_notes_info = invoke("notesInfo", notes=qa_note_ids)
|
|
54
|
+
qa_note_dict = {note["noteId"]: note for note in qa_notes_info}
|
|
55
|
+
|
|
56
|
+
for card in qa_cards_info:
|
|
57
|
+
blocks_with_ids.append(
|
|
58
|
+
(
|
|
59
|
+
card["note"],
|
|
60
|
+
format_card(
|
|
61
|
+
card["cardId"],
|
|
62
|
+
qa_note_dict[card["note"]],
|
|
63
|
+
converter,
|
|
64
|
+
note_type="LayerQA",
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# --- LayerCloze: one block per note (deduplicated) ---
|
|
70
|
+
cloze_query = f'deck:"{deck_name}" -deck:"{deck_name}::*" note:LayerCloze'
|
|
71
|
+
cloze_card_ids = invoke("findCards", query=cloze_query)
|
|
72
|
+
|
|
73
|
+
if cloze_card_ids:
|
|
74
|
+
cloze_cards_info = invoke("cardsInfo", cards=cloze_card_ids)
|
|
75
|
+
cloze_note_ids = list({card["note"] for card in cloze_cards_info})
|
|
76
|
+
cloze_notes_info = invoke("notesInfo", notes=cloze_note_ids)
|
|
77
|
+
|
|
78
|
+
for note in cloze_notes_info:
|
|
79
|
+
blocks_with_ids.append(
|
|
80
|
+
(
|
|
81
|
+
note["noteId"],
|
|
82
|
+
format_card(
|
|
83
|
+
note["noteId"],
|
|
84
|
+
note,
|
|
85
|
+
converter,
|
|
86
|
+
note_type="LayerCloze",
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Build a lookup from block ID string to (note_id, formatted_block)
|
|
92
|
+
block_by_id: dict[str, tuple[int, str]] = {}
|
|
93
|
+
for note_id, block in blocks_with_ids:
|
|
94
|
+
match = re.match(r"<!--\s*((?:card_id|note_id):\s*\d+)\s*-->", block)
|
|
95
|
+
if match:
|
|
96
|
+
key = re.sub(r"\s+", " ", match.group(1))
|
|
97
|
+
block_by_id[key] = (note_id, block)
|
|
98
|
+
|
|
99
|
+
if not blocks_with_ids:
|
|
100
|
+
return DeckExportResult(
|
|
101
|
+
deck_name=deck_name,
|
|
102
|
+
file_path=None,
|
|
103
|
+
total_cards=0,
|
|
104
|
+
updated=0,
|
|
105
|
+
created=0,
|
|
106
|
+
deleted=0,
|
|
107
|
+
skipped=0,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
output_path = Path(output_dir) / (sanitize_filename(deck_name) + ".md")
|
|
111
|
+
deck_id_line = "<!-- deck_id: {} -->".format(deck_id) + "\n" if deck_id else ""
|
|
112
|
+
|
|
113
|
+
# Compare with existing file to determine per-card changes
|
|
114
|
+
updated = 0
|
|
115
|
+
created = 0
|
|
116
|
+
deleted = 0
|
|
117
|
+
skipped = 0
|
|
118
|
+
created_ids: set[str] = set()
|
|
119
|
+
deleted_ids: set[str] = set()
|
|
120
|
+
|
|
121
|
+
old_content = (
|
|
122
|
+
output_path.read_text(encoding="utf-8") if output_path.exists() else None
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if old_content is not None:
|
|
126
|
+
existing_blocks = extract_card_blocks(old_content)
|
|
127
|
+
|
|
128
|
+
# Preserve existing file order; append new cards sorted by creation date.
|
|
129
|
+
new_block_ids = set(block_by_id.keys())
|
|
130
|
+
ordered_blocks: list[str] = []
|
|
131
|
+
|
|
132
|
+
# Keep existing cards in their current order, updating content
|
|
133
|
+
for block_id in existing_blocks:
|
|
134
|
+
if block_id in block_by_id:
|
|
135
|
+
_, block = block_by_id[block_id]
|
|
136
|
+
ordered_blocks.append(block)
|
|
137
|
+
if existing_blocks[block_id] == block:
|
|
138
|
+
skipped += 1
|
|
139
|
+
else:
|
|
140
|
+
updated += 1
|
|
141
|
+
else:
|
|
142
|
+
deleted += 1
|
|
143
|
+
deleted_ids.add(block_id)
|
|
144
|
+
|
|
145
|
+
# Append genuinely new cards, sorted by creation date among themselves
|
|
146
|
+
new_ids = new_block_ids - set(existing_blocks)
|
|
147
|
+
new_entries = sorted(
|
|
148
|
+
((bid, *block_by_id[bid]) for bid in new_ids),
|
|
149
|
+
key=lambda x: x[1], # sort by note_id (creation timestamp)
|
|
150
|
+
)
|
|
151
|
+
for bid, _, block in new_entries:
|
|
152
|
+
ordered_blocks.append(block)
|
|
153
|
+
created += 1
|
|
154
|
+
created_ids.add(bid)
|
|
155
|
+
|
|
156
|
+
markdown_blocks = ordered_blocks
|
|
157
|
+
else:
|
|
158
|
+
# First export for this deck: sort by creation date (chronological).
|
|
159
|
+
blocks_with_ids.sort(key=lambda x: x[0])
|
|
160
|
+
markdown_blocks = [block for _, block in blocks_with_ids]
|
|
161
|
+
created = len(markdown_blocks)
|
|
162
|
+
|
|
163
|
+
cards_content = CARD_SEPARATOR.join(markdown_blocks)
|
|
164
|
+
new_content = deck_id_line + cards_content
|
|
165
|
+
|
|
166
|
+
# Only write if content actually changed
|
|
167
|
+
if old_content != new_content:
|
|
168
|
+
output_path.write_text(new_content, encoding="utf-8")
|
|
169
|
+
|
|
170
|
+
logger.debug(f"{deck_name}: {len(markdown_blocks)} blocks -> {output_path.name}")
|
|
171
|
+
return DeckExportResult(
|
|
172
|
+
deck_name=deck_name,
|
|
173
|
+
file_path=output_path,
|
|
174
|
+
total_cards=len(markdown_blocks),
|
|
175
|
+
updated=updated,
|
|
176
|
+
created=created,
|
|
177
|
+
deleted=deleted,
|
|
178
|
+
skipped=skipped,
|
|
179
|
+
created_ids=created_ids,
|
|
180
|
+
deleted_ids=deleted_ids,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _find_relevant_decks() -> set[str]:
|
|
185
|
+
"""Return deck names that contain LayerQA or LayerCloze notes."""
|
|
186
|
+
query = " OR ".join(f"note:{nt}" for nt in SUPPORTED_NOTE_TYPES)
|
|
187
|
+
card_ids = invoke("findCards", query=query)
|
|
188
|
+
if not card_ids:
|
|
189
|
+
return set()
|
|
190
|
+
cards_info = invoke("cardsInfo", cards=card_ids)
|
|
191
|
+
return {card["deckName"] for card in cards_info}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def transcribe_collection(output_dir: str = ".") -> list[DeckExportResult]:
|
|
195
|
+
"""Transcribe all decks in the collection to Markdown files."""
|
|
196
|
+
deck_names_and_ids = invoke("deckNamesAndIds")
|
|
197
|
+
relevant_decks = _find_relevant_decks()
|
|
198
|
+
logger.info(
|
|
199
|
+
f"Found {len(deck_names_and_ids)} decks, "
|
|
200
|
+
f"{len(relevant_decks)} with supported note types"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Also include decks that have existing markdown files (they may
|
|
204
|
+
# have become empty after cards moved out and need updating).
|
|
205
|
+
output_path = Path(output_dir)
|
|
206
|
+
id_to_name = {v: k for k, v in deck_names_and_ids.items()}
|
|
207
|
+
for md_file in output_path.glob("*.md"):
|
|
208
|
+
content = md_file.read_text(encoding="utf-8")
|
|
209
|
+
deck_id, _ = extract_deck_id(content)
|
|
210
|
+
if deck_id and deck_id in id_to_name:
|
|
211
|
+
relevant_decks.add(id_to_name[deck_id])
|
|
212
|
+
|
|
213
|
+
results = []
|
|
214
|
+
for deck_name in sorted(deck_names_and_ids):
|
|
215
|
+
if deck_name not in relevant_decks:
|
|
216
|
+
continue
|
|
217
|
+
deck_id = deck_names_and_ids[deck_name]
|
|
218
|
+
logger.info(f"Processing {deck_name} (id: {deck_id})...")
|
|
219
|
+
result = transcribe_deck(deck_name, output_dir, deck_id=deck_id)
|
|
220
|
+
if result.file_path:
|
|
221
|
+
results.append(result)
|
|
222
|
+
|
|
223
|
+
if result.total_cards > 0:
|
|
224
|
+
logger.info(
|
|
225
|
+
f" Updated: {result.updated}, Created: {result.created}, "
|
|
226
|
+
f"Deleted: {result.deleted}, Skipped: {result.skipped}"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Check for cross-deck moves: a card/note ID that disappeared from
|
|
230
|
+
# one file and appeared in another was moved between decks in Anki.
|
|
231
|
+
all_created_ids: set[str] = set()
|
|
232
|
+
all_deleted_ids: set[str] = set()
|
|
233
|
+
for r in results:
|
|
234
|
+
all_created_ids.update(r.created_ids or set())
|
|
235
|
+
all_deleted_ids.update(r.deleted_ids or set())
|
|
236
|
+
moved = len(all_created_ids & all_deleted_ids)
|
|
237
|
+
if moved:
|
|
238
|
+
logger.info(
|
|
239
|
+
f" Note: {moved} of the above created/deleted card(s) were "
|
|
240
|
+
f"moved between decks (review history is preserved)"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return results
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def rename_markdown_files(output_dir: str = ".") -> int:
|
|
247
|
+
"""Rename markdown files to match their Anki deck name via deck_id.
|
|
248
|
+
|
|
249
|
+
If a deck was renamed in Anki, the corresponding markdown file is
|
|
250
|
+
renamed to reflect the new deck name. The deck_id inside the file
|
|
251
|
+
is used to link the file to its deck.
|
|
252
|
+
Returns the number of renamed files.
|
|
253
|
+
"""
|
|
254
|
+
deck_names_and_ids = invoke("deckNamesAndIds")
|
|
255
|
+
id_to_name = {v: k for k, v in deck_names_and_ids.items()}
|
|
256
|
+
renamed = 0
|
|
257
|
+
|
|
258
|
+
for md_file in Path(output_dir).glob("*.md"):
|
|
259
|
+
content = md_file.read_text(encoding="utf-8")
|
|
260
|
+
deck_id, _ = extract_deck_id(content)
|
|
261
|
+
if deck_id is None or deck_id not in id_to_name:
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
expected_filename = sanitize_filename(id_to_name[deck_id]) + ".md"
|
|
265
|
+
if md_file.name != expected_filename:
|
|
266
|
+
new_path = md_file.parent / expected_filename
|
|
267
|
+
logger.info(f" Renamed {md_file.name} -> {expected_filename}")
|
|
268
|
+
md_file.rename(new_path)
|
|
269
|
+
renamed += 1
|
|
270
|
+
|
|
271
|
+
return renamed
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def delete_orphaned_decks(output_dir: str = ".") -> int:
|
|
275
|
+
"""Delete markdown files whose deck_id is not found in Anki.
|
|
276
|
+
|
|
277
|
+
Files without a deck_id are kept (they are likely new decks pending first sync).
|
|
278
|
+
Returns the number of deleted files.
|
|
279
|
+
"""
|
|
280
|
+
anki_deck_ids = set(invoke("deckNamesAndIds").values())
|
|
281
|
+
deleted = 0
|
|
282
|
+
|
|
283
|
+
for md_file in Path(output_dir).glob("*.md"):
|
|
284
|
+
content = md_file.read_text(encoding="utf-8")
|
|
285
|
+
deck_id, _ = extract_deck_id(content)
|
|
286
|
+
if deck_id is not None and deck_id not in anki_deck_ids:
|
|
287
|
+
logger.info(
|
|
288
|
+
f" Deleting orphaned deck file {md_file.name} (deck_id: {deck_id})"
|
|
289
|
+
)
|
|
290
|
+
md_file.unlink()
|
|
291
|
+
deleted += 1
|
|
292
|
+
|
|
293
|
+
return deleted
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def delete_orphaned_cards(output_dir: str = ".") -> int:
|
|
297
|
+
"""Delete cards/notes from markdown files whose IDs are not found in Anki.
|
|
298
|
+
|
|
299
|
+
Cards without an ID are kept (they are new cards pending first sync).
|
|
300
|
+
Returns the total number of deleted blocks.
|
|
301
|
+
"""
|
|
302
|
+
anki_card_ids = set(invoke("findCards", query="deck:*"))
|
|
303
|
+
# Get all note IDs for note_id-based blocks (LayerCloze)
|
|
304
|
+
anki_note_ids: set[int] = set()
|
|
305
|
+
for note_type in SUPPORTED_NOTE_TYPES:
|
|
306
|
+
note_ids = invoke("findNotes", query=f"note:{note_type}")
|
|
307
|
+
anki_note_ids.update(note_ids)
|
|
308
|
+
|
|
309
|
+
total_deleted = 0
|
|
310
|
+
|
|
311
|
+
for md_file in Path(output_dir).glob("*.md"):
|
|
312
|
+
content = md_file.read_text(encoding="utf-8")
|
|
313
|
+
deck_id_line_match = re.match(r"(<!--\s*deck_id:\s*\d+\s*-->\n?)", content)
|
|
314
|
+
deck_id_prefix = deck_id_line_match.group(1) if deck_id_line_match else ""
|
|
315
|
+
_, cards_content = extract_deck_id(content)
|
|
316
|
+
|
|
317
|
+
blocks = cards_content.split(CARD_SEPARATOR)
|
|
318
|
+
kept: list[str] = []
|
|
319
|
+
deleted = 0
|
|
320
|
+
|
|
321
|
+
for block in blocks:
|
|
322
|
+
stripped = block.strip()
|
|
323
|
+
if not stripped:
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
card_match = re.match(r"<!--\s*card_id:\s*(\d+)\s*-->", stripped)
|
|
327
|
+
if card_match:
|
|
328
|
+
card_id = int(card_match.group(1))
|
|
329
|
+
if card_id not in anki_card_ids:
|
|
330
|
+
deleted += 1
|
|
331
|
+
logger.info(f" Deleting card {card_id} from {md_file.name}")
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
note_match = re.match(r"<!--\s*note_id:\s*(\d+)\s*-->", stripped)
|
|
335
|
+
if note_match:
|
|
336
|
+
note_id = int(note_match.group(1))
|
|
337
|
+
if note_id not in anki_note_ids:
|
|
338
|
+
deleted += 1
|
|
339
|
+
logger.info(f" Deleting note {note_id} from {md_file.name}")
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
kept.append(stripped)
|
|
343
|
+
|
|
344
|
+
if deleted > 0:
|
|
345
|
+
new_content = deck_id_prefix + CARD_SEPARATOR.join(kept)
|
|
346
|
+
md_file.write_text(new_content, encoding="utf-8")
|
|
347
|
+
logger.info(f"{md_file.name}: deleted {deleted} orphaned block(s)")
|
|
348
|
+
total_deleted += deleted
|
|
349
|
+
|
|
350
|
+
return total_deleted
|