kunyi 0.1.0__py3-none-any.whl

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.
kunyi/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """kunyi — generate Anki .apkg decks from structured card data."""
2
+
3
+ from kunyi.card_types import BasicCard, MCQCard
4
+ from kunyi.deck import AnkiCardDeck
5
+
6
+ __all__ = ["AnkiCardDeck", "BasicCard", "MCQCard"]
kunyi/__main__.py ADDED
@@ -0,0 +1,142 @@
1
+ """CLI entry point for kunyi.
2
+
3
+ Usage
4
+ -----
5
+ kunyi <deck_name> <input_file> [--format {json,tsv}] [--output PATH]
6
+
7
+ On success: the resolved output path is printed to stdout, exit 0.
8
+ On failure: a human-readable message is printed to stderr, exit 1.
9
+
10
+ This contract is designed for subprocess callers (e.g. Seya/Dart):
11
+ - check the exit code
12
+ - on 0: read stdout as the .apkg path
13
+ - on non-zero: read stderr for the error message
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from kunyi.card_types import BasicCard, MCQCard
23
+ from kunyi.deck import AnkiCardDeck
24
+ from kunyi.parsers import parse_json, parse_tsv
25
+
26
+
27
+ def _detect_format(path: Path) -> str:
28
+ """Infer input format from file extension.
29
+
30
+ Parameters
31
+ ----------
32
+ path:
33
+ Input file path.
34
+
35
+ Returns
36
+ -------
37
+ str
38
+ "json" or "tsv".
39
+
40
+ Raises
41
+ ------
42
+ ValueError
43
+ If the extension is not recognised.
44
+ """
45
+ suffix = path.suffix.lower()
46
+ if suffix == ".json":
47
+ return "json"
48
+ if suffix in {".tsv", ".txt"}:
49
+ return "tsv"
50
+ raise ValueError(
51
+ f"Cannot infer format from extension {suffix!r}. "
52
+ "Use --format {{json,tsv}} to specify explicitly."
53
+ )
54
+
55
+
56
+ def _resolve_output(deck_name: str, output: str | None) -> Path:
57
+ """Resolve the .apkg output path.
58
+
59
+ Parameters
60
+ ----------
61
+ deck_name:
62
+ Deck name used to build the default filename.
63
+ output:
64
+ Explicit output path from --output, or None for the default.
65
+
66
+ Returns
67
+ -------
68
+ Path
69
+ Resolved output path.
70
+ """
71
+ if output is not None:
72
+ return Path(output)
73
+ safe_name = deck_name.replace(" ", "_")
74
+ return Path(f"{safe_name}.apkg")
75
+
76
+
77
+ def main() -> None:
78
+ """Parse arguments and generate the .apkg deck."""
79
+ parser = argparse.ArgumentParser(
80
+ prog="kunyi",
81
+ description="Generate an Anki .apkg deck from JSON or TSV card data.",
82
+ )
83
+ parser.add_argument("deck_name", help="Name of the Anki deck.")
84
+ parser.add_argument("input_file", help="Path to the input file (.json or .tsv).")
85
+ parser.add_argument(
86
+ "--format",
87
+ choices=["json", "tsv"],
88
+ default=None,
89
+ help="Input format. Defaults to auto-detection by file extension.",
90
+ )
91
+ parser.add_argument(
92
+ "--output",
93
+ default=None,
94
+ metavar="PATH",
95
+ help=(
96
+ "Output path for the .apkg file. "
97
+ "Defaults to <deck_name>.apkg in the current directory."
98
+ ),
99
+ )
100
+
101
+ args = parser.parse_args()
102
+ input_path = Path(args.input_file)
103
+
104
+ # Resolve format: explicit flag wins over extension detection.
105
+ try:
106
+ fmt = args.format or _detect_format(input_path)
107
+ except ValueError as exc:
108
+ print(f"error: {exc}", file=sys.stderr)
109
+ sys.exit(1)
110
+
111
+ # Parse input file.
112
+ try:
113
+ if fmt == "json":
114
+ cards: list[BasicCard | MCQCard] = parse_json(input_path)
115
+ else:
116
+ cards = parse_tsv(input_path)
117
+ except FileNotFoundError:
118
+ print(f"error: input file not found: {input_path}", file=sys.stderr)
119
+ sys.exit(1)
120
+ except (KeyError, ValueError) as exc:
121
+ print(f"error: failed to parse {input_path}: {exc}", file=sys.stderr)
122
+ sys.exit(1)
123
+
124
+ # Build and save deck.
125
+ output_path = _resolve_output(args.deck_name, args.output)
126
+
127
+ try:
128
+ output_path.parent.mkdir(parents=True, exist_ok=True)
129
+ deck = AnkiCardDeck(deck_name=args.deck_name)
130
+ for card in cards:
131
+ deck.add_card(card)
132
+ deck.save_deck(output_path)
133
+ except Exception as exc: # noqa: BLE001
134
+ print(f"error: failed to write deck: {exc}", file=sys.stderr)
135
+ sys.exit(1)
136
+
137
+ # Print resolved path to stdout for subprocess callers.
138
+ print(output_path.resolve())
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()
kunyi/card_types.py ADDED
@@ -0,0 +1,70 @@
1
+ """Card type dataclasses for kunyi.
2
+
3
+ Each dataclass is a pure data container with no genanki dependency.
4
+ Optional fields (tags, media_paths) are reserved for future use and map
5
+ directly to genanki.Note(tags=...) and genanki.Package(media_files=...).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+
14
+ @dataclass
15
+ class BasicCard:
16
+ """A simple two-sided flashcard.
17
+
18
+ Parameters
19
+ ----------
20
+ front:
21
+ Text rendered on the question side of the card.
22
+ back:
23
+ Text rendered on the answer side of the card.
24
+ tags:
25
+ Optional Anki tags attached to the note (e.g. ["chapter-3", "exam"]).
26
+ media_paths:
27
+ Optional paths to media files (images, audio) referenced by this card.
28
+ Collected at deck-save time and registered with the genanki Package.
29
+ """
30
+
31
+ front: str
32
+ back: str
33
+ tags: list[str] = field(default_factory=list)
34
+ media_paths: list[Path] = field(default_factory=list)
35
+
36
+
37
+ @dataclass
38
+ class MCQCard:
39
+ """A multiple-choice question card.
40
+
41
+ Parameters
42
+ ----------
43
+ question:
44
+ The question stem displayed on the front of the card.
45
+ choices:
46
+ Ordered list of answer options shown below the question.
47
+ correct_answer:
48
+ The correct choice string; must be an element of *choices*.
49
+ Validated on construction so upstream LLM output is caught early.
50
+ explanation:
51
+ Explanation of the correct answer displayed on the card back.
52
+ tags:
53
+ Optional Anki tags attached to the note.
54
+ media_paths:
55
+ Optional paths to media files referenced by this card.
56
+ """
57
+
58
+ question: str
59
+ choices: list[str]
60
+ correct_answer: str
61
+ explanation: str
62
+ tags: list[str] = field(default_factory=list)
63
+ media_paths: list[Path] = field(default_factory=list)
64
+
65
+ def __post_init__(self) -> None:
66
+ """Validate that correct_answer is a member of choices."""
67
+ if self.correct_answer not in self.choices:
68
+ raise ValueError(
69
+ f"correct_answer {self.correct_answer!r} is not in choices: {self.choices}"
70
+ )
kunyi/deck.py ADDED
@@ -0,0 +1,191 @@
1
+ """Deck orchestration for kunyi.
2
+
3
+ AnkiCardDeck wraps a genanki.Deck and handles model selection, note
4
+ construction, and media collection. It accepts both BasicCard and MCQCard
5
+ objects via a single add_card() method and dispatches internally.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import random
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Union
14
+
15
+ import genanki
16
+
17
+ from kunyi.card_types import BasicCard, MCQCard
18
+ from kunyi.models import basic_model, mcq_model
19
+
20
+ Card = Union[BasicCard, MCQCard]
21
+
22
+
23
+ class AnkiCardDeck:
24
+ """Build an Anki deck from BasicCard and MCQCard objects.
25
+
26
+ A single deck can hold mixed card types. Each type uses its own genanki
27
+ Model (keyed by a deterministic model_id derived from deck_id). Notes are
28
+ added in insertion order.
29
+
30
+ Media file paths collected from card.media_paths are passed to
31
+ genanki.Package at save time. The actual media wiring is reserved — files
32
+ are registered but card templates do not yet reference them by name.
33
+
34
+ Parameters
35
+ ----------
36
+ deck_name:
37
+ Human-readable name shown in Anki.
38
+ deck_id:
39
+ Optional stable integer deck ID. Defaults to a time-based value.
40
+ Use a fixed value when regenerating the same deck so Anki updates
41
+ existing notes instead of creating duplicates.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ deck_name: str,
47
+ deck_id: int | None = None,
48
+ ) -> None:
49
+ self.deck_name = deck_name
50
+ self.deck_id: int = deck_id if deck_id is not None else int(time.time())
51
+
52
+ # Derive stable model IDs from deck_id so they travel together.
53
+ self._basic_model: genanki.Model = basic_model(self.deck_id + 1)
54
+ self._mcq_model: genanki.Model = mcq_model(self.deck_id + 2)
55
+
56
+ self._deck: genanki.Deck = genanki.Deck(self.deck_id, self.deck_name)
57
+ self._media_files: list[str] = []
58
+
59
+ # ------------------------------------------------------------------
60
+ # Public interface
61
+ # ------------------------------------------------------------------
62
+
63
+ def add_card(self, card: Card) -> None:
64
+ """Add a single card to the deck.
65
+
66
+ Dispatches to the correct note builder based on card type.
67
+
68
+ Parameters
69
+ ----------
70
+ card:
71
+ A BasicCard or MCQCard instance.
72
+
73
+ Raises
74
+ ------
75
+ TypeError
76
+ If card is not a recognised card type.
77
+ """
78
+ if isinstance(card, BasicCard):
79
+ note = self._build_basic_note(card)
80
+ elif isinstance(card, MCQCard):
81
+ note = self._build_mcq_note(card)
82
+ else:
83
+ raise TypeError(f"Unsupported card type: {type(card)!r}")
84
+
85
+ self._deck.add_note(note)
86
+
87
+ # Accumulate media for registration at save time.
88
+ for media_path in card.media_paths:
89
+ self._media_files.append(str(media_path))
90
+
91
+ def save_deck(self, output_path: Path) -> None:
92
+ """Write the deck to an .apkg file.
93
+
94
+ Parameters
95
+ ----------
96
+ output_path:
97
+ Destination path for the .apkg file. Parent directories must
98
+ already exist; callers are responsible for creation.
99
+ """
100
+ package = genanki.Package(self._deck)
101
+ # Media wiring: reserved for future use. Files are registered here
102
+ # so the field exists in the package; card templates will reference
103
+ # them by filename once image/audio card types are added.
104
+ if self._media_files:
105
+ package.media_files = self._media_files
106
+
107
+ package.write_to_file(str(output_path))
108
+
109
+ # ------------------------------------------------------------------
110
+ # Private note builders
111
+ # ------------------------------------------------------------------
112
+
113
+ def _build_basic_note(self, card: BasicCard) -> genanki.Note:
114
+ """Construct a genanki Note from a BasicCard.
115
+
116
+ Parameters
117
+ ----------
118
+ card:
119
+ Source BasicCard.
120
+
121
+ Returns
122
+ -------
123
+ genanki.Note
124
+ Note bound to the basic model with Front and Back fields.
125
+ """
126
+ return genanki.Note(
127
+ model=self._basic_model,
128
+ fields=[card.front, card.back],
129
+ tags=card.tags,
130
+ )
131
+
132
+ def _build_mcq_note(self, card: MCQCard) -> genanki.Note:
133
+ """Construct a genanki Note from an MCQCard.
134
+
135
+ The Question field contains the formatted question stem and numbered
136
+ choices separated by HTML line breaks. The Answer field contains the
137
+ correct answer and explanation in bold-labelled HTML.
138
+
139
+ Parameters
140
+ ----------
141
+ card:
142
+ Source MCQCard.
143
+
144
+ Returns
145
+ -------
146
+ genanki.Note
147
+ Note bound to the MCQ model.
148
+ """
149
+ return genanki.Note(
150
+ model=self._mcq_model,
151
+ fields=[
152
+ self._format_mcq_front(card),
153
+ self._format_mcq_back(card),
154
+ ],
155
+ tags=card.tags,
156
+ )
157
+
158
+ @staticmethod
159
+ def _format_mcq_front(card: MCQCard) -> str:
160
+ """Render the front HTML for an MCQCard.
161
+
162
+ Parameters
163
+ ----------
164
+ card:
165
+ Source MCQCard.
166
+
167
+ Returns
168
+ -------
169
+ str
170
+ HTML string: question stem followed by numbered choices.
171
+ """
172
+ numbered = "<br>".join(f"{i}. {c}" for i, c in enumerate(card.choices, 1))
173
+ return f"{card.question}<br><br>{numbered}"
174
+
175
+ @staticmethod
176
+ def _format_mcq_back(card: MCQCard) -> str:
177
+ """Render the back HTML for an MCQCard.
178
+
179
+ Parameters
180
+ ----------
181
+ card:
182
+ Source MCQCard.
183
+
184
+ Returns
185
+ -------
186
+ str
187
+ HTML string: bolded correct answer label followed by explanation.
188
+ """
189
+ answer_line = f"<strong>Correct answer:</strong> {card.correct_answer}<br><br>"
190
+ explanation_line = f"<strong>Explanation:</strong> {card.explanation}"
191
+ return answer_line + explanation_line
kunyi/models.py ADDED
@@ -0,0 +1,73 @@
1
+ """Genanki model factories for each card type.
2
+
3
+ Each factory returns a configured genanki.Model. Keeping model construction
4
+ here isolates the Anki-specific template/field schema from deck orchestration
5
+ in deck.py. To add a new card type, add a new factory here and register it
6
+ in deck.py's dispatch logic.
7
+ """
8
+
9
+ import genanki
10
+
11
+
12
+ def basic_model(model_id: int) -> genanki.Model:
13
+ """Return a genanki Model for BasicCard (front / back).
14
+
15
+ Parameters
16
+ ----------
17
+ model_id:
18
+ Unique integer ID for the model. Caller is responsible for stability
19
+ across regenerations so Anki can match notes to the correct model.
20
+
21
+ Returns
22
+ -------
23
+ genanki.Model
24
+ A two-field model rendering Front on the question side and Back on
25
+ the answer side.
26
+ """
27
+ return genanki.Model(
28
+ model_id,
29
+ "Basic",
30
+ fields=[
31
+ {"name": "Front"},
32
+ {"name": "Back"},
33
+ ],
34
+ templates=[
35
+ {
36
+ "name": "Card 1",
37
+ "qfmt": "{{Front}}",
38
+ "afmt": "{{FrontSide}}<hr id=answer>{{Back}}",
39
+ },
40
+ ],
41
+ )
42
+
43
+
44
+ def mcq_model(model_id: int) -> genanki.Model:
45
+ """Return a genanki Model for MCQCard (question + choices / answer + explanation).
46
+
47
+ Parameters
48
+ ----------
49
+ model_id:
50
+ Unique integer ID for the model.
51
+
52
+ Returns
53
+ -------
54
+ genanki.Model
55
+ A two-field model where the Question field contains the formatted
56
+ question + numbered choices and the Answer field contains the correct
57
+ answer and explanation.
58
+ """
59
+ return genanki.Model(
60
+ model_id,
61
+ "MCQ with explanation",
62
+ fields=[
63
+ {"name": "Question"},
64
+ {"name": "Answer"},
65
+ ],
66
+ templates=[
67
+ {
68
+ "name": "Card 1",
69
+ "qfmt": "{{Question}}",
70
+ "afmt": "{{FrontSide}}<hr id=answer>{{Answer}}",
71
+ },
72
+ ],
73
+ )
kunyi/parsers.py ADDED
@@ -0,0 +1,120 @@
1
+ """Input parsers for kunyi.
2
+
3
+ Each parser reads a file and returns a list of typed card objects.
4
+ Supported formats:
5
+ - JSON: MCQCard list (see README for schema)
6
+ - TSV: BasicCard list with columns front<TAB>back (optional header row)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ import json
13
+ from pathlib import Path
14
+
15
+ from kunyi.card_types import BasicCard, MCQCard
16
+
17
+
18
+ def parse_json(path: Path) -> list[MCQCard]:
19
+ """Parse a JSON file into a list of MCQCard objects.
20
+
21
+ Expected schema::
22
+
23
+ {
24
+ "cards": [
25
+ {
26
+ "question": "...",
27
+ "choices": ["A", "B", "C", "D"],
28
+ "correct_answer": "A",
29
+ "explanation": "..."
30
+ }
31
+ ]
32
+ }
33
+
34
+ The top-level "deck_name" key is ignored here; callers pass deck_name
35
+ separately so the parser stays stateless.
36
+
37
+ Parameters
38
+ ----------
39
+ path:
40
+ Path to the JSON file.
41
+
42
+ Returns
43
+ -------
44
+ list[MCQCard]
45
+ Parsed and validated card objects.
46
+
47
+ Raises
48
+ ------
49
+ KeyError
50
+ If a required field is missing from a card entry.
51
+ ValueError
52
+ If correct_answer is not in choices (propagated from MCQCard).
53
+ """
54
+ with path.open(encoding="utf-8") as fh:
55
+ data: dict = json.load(fh)
56
+
57
+ cards: list[MCQCard] = []
58
+ for entry in data["cards"]:
59
+ cards.append(
60
+ MCQCard(
61
+ question=entry["question"],
62
+ choices=entry["choices"],
63
+ correct_answer=entry["correct_answer"],
64
+ explanation=entry["explanation"],
65
+ tags=entry.get("tags", []),
66
+ )
67
+ )
68
+ return cards
69
+
70
+
71
+ def parse_tsv(path: Path) -> list[BasicCard]:
72
+ """Parse a TSV file into a list of BasicCard objects.
73
+
74
+ The file must be tab-delimited UTF-8. An optional header row is detected
75
+ automatically: if the first row contains the literal values "front" and
76
+ "back" (case-insensitive) it is skipped. All other rows are treated as
77
+ card data.
78
+
79
+ Expected layout::
80
+
81
+ front<TAB>back
82
+ What is spaced repetition?<TAB>A technique that spaces reviews over time.
83
+
84
+ Parameters
85
+ ----------
86
+ path:
87
+ Path to the TSV file.
88
+
89
+ Returns
90
+ -------
91
+ list[BasicCard]
92
+ Parsed card objects.
93
+
94
+ Raises
95
+ ------
96
+ ValueError
97
+ If a row does not have at least two tab-separated columns.
98
+ """
99
+ cards: list[BasicCard] = []
100
+
101
+ with path.open(encoding="utf-8", newline="") as fh:
102
+ reader = csv.reader(fh, delimiter="\t")
103
+ rows = list(reader)
104
+
105
+ if not rows:
106
+ return cards
107
+
108
+ # Detect and skip header row.
109
+ first = rows[0]
110
+ if len(first) >= 2 and first[0].strip().lower() == "front" and first[1].strip().lower() == "back":
111
+ rows = rows[1:]
112
+
113
+ for i, row in enumerate(rows, start=2): # line numbers for error messages
114
+ if len(row) < 2:
115
+ raise ValueError(
116
+ f"TSV row {i} has fewer than 2 columns: {row!r}"
117
+ )
118
+ cards.append(BasicCard(front=row[0].strip(), back=row[1].strip()))
119
+
120
+ return cards
@@ -0,0 +1,257 @@
1
+ Metadata-Version: 2.4
2
+ Name: kunyi
3
+ Version: 0.1.0
4
+ Summary: Generate Anki .apkg decks from JSON (MCQ) or TSV (basic) card data.
5
+ Project-URL: Homepage, https://github.com/LingT03/Kunyi
6
+ Project-URL: Bug Tracker, https://github.com/LingT03/Kunyi/issues
7
+ Project-URL: Changelog, https://github.com/LingT03/Kunyi/releases
8
+ License: PolyForm Noncommercial License 1.0.0
9
+
10
+ https://polyformproject.org/licenses/noncommercial/1.0.0
11
+
12
+ Required Notice: Copyright Ta'taang (Ling Thang) (https://github.com/LingT03)
13
+
14
+ Acceptance
15
+
16
+ In order to get any license under these terms, you must agree
17
+ to them as both strict obligations and conditions to all
18
+ your licenses.
19
+
20
+ Copyright License
21
+
22
+ The licensor grants you a copyright license for the
23
+ software to do everything you might do with the software
24
+ that would otherwise infringe the licensor's copyright
25
+ in it for any permitted purpose. However, you may
26
+ only distribute the software according to Distribution License
27
+ and make changes or new works based on the software according
28
+ to Changes and New Works License.
29
+
30
+ Distribution License
31
+
32
+ The licensor grants you an additional copyright license
33
+ to distribute copies of the software. Your license
34
+ to distribute covers distributing the software with
35
+ changes and new works permitted by Changes and New Works License.
36
+
37
+ Notices
38
+
39
+ You must ensure that anyone who gets a copy of any part of
40
+ the software from you also gets a copy of these terms or the
41
+ URL for them above, as well as copies of any plain-text lines
42
+ beginning with "Required Notice:" that the licensor provided
43
+ with the software.
44
+
45
+ Changes and New Works License
46
+
47
+ The licensor grants you an additional copyright license to
48
+ make changes and new works based on the software for any
49
+ permitted purpose.
50
+
51
+ Patent License
52
+
53
+ The licensor grants you a patent license for the software that
54
+ covers patent claims the licensor can license, or becomes able
55
+ to license, that you would infringe by using the software.
56
+
57
+ Noncommercial Purposes
58
+
59
+ Any noncommercial purpose is a permitted purpose.
60
+
61
+ Personal Uses
62
+
63
+ Personal use for research, experiment, and testing for
64
+ the benefit of public knowledge, personal study, private
65
+ entertainment, hobby projects, amateur pursuits, or religious
66
+ observance, without any anticipated commercial application,
67
+ is use for a permitted purpose.
68
+
69
+ Noncommercial Organizations
70
+
71
+ Use by any charitable organization, educational institution,
72
+ public research organization, public safety or health
73
+ organization, environmental protection organization,
74
+ or government institution is use for a permitted purpose
75
+ regardless of the source of funding or obligations resulting
76
+ from the funding.
77
+
78
+ Fair Use
79
+
80
+ You may have "fair use" rights for the software under the
81
+ law. These terms do not limit them.
82
+
83
+ No Other Rights
84
+
85
+ These terms do not allow you to sublicense or transfer any of
86
+ your licenses to anyone else, or prevent the licensor from
87
+ granting licenses to anyone else. These terms do not imply
88
+ any other licenses.
89
+
90
+ Patent Defense
91
+
92
+ If you make any written claim that the software infringes or
93
+ contributes to infringement of any patent, your patent license
94
+ for the software granted under these terms ends immediately. If
95
+ your company makes such a claim, your patent license ends
96
+ immediately for work on behalf of your company.
97
+
98
+ Violations
99
+
100
+ The first time you are notified in writing that you have
101
+ violated any of these terms, or done anything with the software
102
+ not covered by your licenses, your licenses can nonetheless
103
+ continue if you come into full compliance with these terms,
104
+ and take practical steps to correct past violations, within
105
+ 32 days of receiving notice. Otherwise, all your licenses
106
+ end immediately.
107
+
108
+ No Liability
109
+
110
+ AS FAR AS THE LAW ALLOWS, THE SOFTWARE COMES AS IS, WITHOUT
111
+ ANY WARRANTY OR CONDITION, AND THE LICENSOR WILL NOT BE LIABLE
112
+ TO YOU FOR ANY DAMAGES ARISING OUT OF THESE TERMS OR THE USE
113
+ OR NATURE OF THE SOFTWARE, UNDER ANY KIND OF LEGAL CLAIM.
114
+
115
+ Definitions
116
+
117
+ The "licensor" is the individual or entity offering these
118
+ terms, and the "software" is the software the licensor makes
119
+ available under these terms.
120
+
121
+ "You" refers to the individual or entity agreeing to these
122
+ terms.
123
+
124
+ "Your company" is any legal entity, sole proprietorship,
125
+ or other kind of organization that you work for, plus all
126
+ organizations that have control over, are under the control of,
127
+ or are under common control with that organization. "Control"
128
+ means ownership of substantially all the assets of an entity,
129
+ or the power to direct its management and policies by vote,
130
+ contract, or otherwise. Control can be direct or indirect.
131
+
132
+ "Your licenses" are all the licenses granted to you for the
133
+ software under these terms.
134
+
135
+ "Use" means anything you do with the software requiring one
136
+ of your licenses.
137
+ License-File: LICENSE
138
+ Requires-Python: >=3.10
139
+ Requires-Dist: genanki>=0.13
140
+ Description-Content-Type: text/markdown
141
+
142
+ # kunyi
143
+
144
+ Generate Anki `.apkg` decks from JSON (MCQ) or TSV (basic) card data.
145
+
146
+ _Part of the [Seya](https://github.com/LingT03/Seya) study ecosystem._
147
+
148
+ ---
149
+
150
+ ## Installation
151
+
152
+ ```bash
153
+ pip install kunyi
154
+ ```
155
+
156
+ Or for local development:
157
+
158
+ ```bash
159
+ git clone https://github.com/LingT03/Kunyi.git
160
+ cd Kunyi
161
+ pip install -e .
162
+ ```
163
+
164
+ ---
165
+
166
+ ## CLI usage
167
+
168
+ ```bash
169
+ # JSON — multiple-choice question cards
170
+ kunyi "Cloud Computing" cards.json
171
+
172
+ # TSV — basic front/back cards
173
+ kunyi "Calc 2 Formulas" formulas.tsv
174
+
175
+ # Explicit output path (recommended for subprocess callers)
176
+ kunyi "Cloud Computing" cards.json --output /path/to/deck.apkg
177
+
178
+ # Override format detection
179
+ kunyi "My Deck" cards.data --format tsv
180
+ ```
181
+
182
+ On success the resolved `.apkg` path is printed to stdout (exit 0).
183
+ On failure a human-readable message is printed to stderr (exit 1).
184
+
185
+ ---
186
+
187
+ ## Input formats
188
+
189
+ ### JSON — MCQ cards
190
+
191
+ ```json
192
+ {
193
+ "cards": [
194
+ {
195
+ "question": "What does CPU stand for?",
196
+ "choices": ["Central Processing Unit", "Core Power Unit", "Control Processing Unit"],
197
+ "correct_answer": "Central Processing Unit",
198
+ "explanation": "CPU stands for Central Processing Unit.",
199
+ "tags": ["chapter-1"]
200
+ }
201
+ ]
202
+ }
203
+ ```
204
+
205
+ `correct_answer` must be an element of `choices` — validated on parse.
206
+ `tags` is optional.
207
+
208
+ ### TSV — basic cards
209
+
210
+ Tab-delimited UTF-8. Optional header row (`front\tback`) is auto-detected and skipped.
211
+
212
+ ```
213
+ front back
214
+ What is spaced repetition? A technique that spaces reviews over time.
215
+ What is active recall? Actively retrieving information from memory.
216
+ ```
217
+
218
+ ---
219
+
220
+ ## Library usage
221
+
222
+ ```python
223
+ from pathlib import Path
224
+ from kunyi import AnkiCardDeck, MCQCard, BasicCard
225
+
226
+ deck = AnkiCardDeck(deck_name="My Deck")
227
+
228
+ deck.add_card(MCQCard(
229
+ question="What does RAM stand for?",
230
+ choices=["Random Access Memory", "Read Access Module"],
231
+ correct_answer="Random Access Memory",
232
+ explanation="RAM is the primary short-term memory of a computer.",
233
+ tags=["hardware"],
234
+ ))
235
+
236
+ deck.add_card(BasicCard(
237
+ front="What is a CPU?",
238
+ back="The central processing unit — the brain of a computer.",
239
+ ))
240
+
241
+ deck.save_deck(Path("output/my_deck.apkg"))
242
+ ```
243
+
244
+ ---
245
+
246
+ ## Running tests
247
+
248
+ ```bash
249
+ pip install pytest
250
+ pytest tests/
251
+ ```
252
+
253
+ ---
254
+
255
+ ## What is Anki?
256
+
257
+ [Anki](https://apps.ankiweb.net/) is a free flashcard program that uses spaced repetition and active recall to maximise long-term retention. `kunyi` generates `.apkg` files that can be imported directly into Anki.
@@ -0,0 +1,11 @@
1
+ kunyi/__init__.py,sha256=t7HHuKT_JQWr_1GXqJVbD-BX6BRvvyJzh4RfOc57NOU,206
2
+ kunyi/__main__.py,sha256=NAGinrW6d1GFzZydJLxhn_Bf_MPth5we605xcDbteO8,3874
3
+ kunyi/card_types.py,sha256=WPs0Uu_M_3nebQuIZ3x9NhlEHdryMrYVPb9_jWrvvrs,2130
4
+ kunyi/deck.py,sha256=wldRYiothHuMYQbvk2aWBtIEPw076UKS6dp48N0O5ls,5970
5
+ kunyi/models.py,sha256=kGhC7Y2W5QnBbjHFP33UzHfPWsu7TrX3Xb8abLaNvkQ,1967
6
+ kunyi/parsers.py,sha256=ZBwgcgDEdlcBQ6atFOBozykLCitMACWPQhu4BnY-MJc,3095
7
+ kunyi-0.1.0.dist-info/METADATA,sha256=vwHlhXfUDDuPoiYaz0mNzU7_ajyV1pZNuhfHdP2q6BM,8325
8
+ kunyi-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ kunyi-0.1.0.dist-info/entry_points.txt,sha256=wFzkP73VrsnCbZ-uGLjLyig_2JXcUQLoa0vKkC9Gfg4,46
10
+ kunyi-0.1.0.dist-info/licenses/LICENSE,sha256=vkeG3u7f9pHcvQEh8vK8sbe1yV2jcbaeEzux__ru1ng,4394
11
+ kunyi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kunyi = kunyi.__main__:main
@@ -0,0 +1,129 @@
1
+ PolyForm Noncommercial License 1.0.0
2
+
3
+ https://polyformproject.org/licenses/noncommercial/1.0.0
4
+
5
+ Required Notice: Copyright Ta'taang (Ling Thang) (https://github.com/LingT03)
6
+
7
+ Acceptance
8
+
9
+ In order to get any license under these terms, you must agree
10
+ to them as both strict obligations and conditions to all
11
+ your licenses.
12
+
13
+ Copyright License
14
+
15
+ The licensor grants you a copyright license for the
16
+ software to do everything you might do with the software
17
+ that would otherwise infringe the licensor's copyright
18
+ in it for any permitted purpose. However, you may
19
+ only distribute the software according to Distribution License
20
+ and make changes or new works based on the software according
21
+ to Changes and New Works License.
22
+
23
+ Distribution License
24
+
25
+ The licensor grants you an additional copyright license
26
+ to distribute copies of the software. Your license
27
+ to distribute covers distributing the software with
28
+ changes and new works permitted by Changes and New Works License.
29
+
30
+ Notices
31
+
32
+ You must ensure that anyone who gets a copy of any part of
33
+ the software from you also gets a copy of these terms or the
34
+ URL for them above, as well as copies of any plain-text lines
35
+ beginning with "Required Notice:" that the licensor provided
36
+ with the software.
37
+
38
+ Changes and New Works License
39
+
40
+ The licensor grants you an additional copyright license to
41
+ make changes and new works based on the software for any
42
+ permitted purpose.
43
+
44
+ Patent License
45
+
46
+ The licensor grants you a patent license for the software that
47
+ covers patent claims the licensor can license, or becomes able
48
+ to license, that you would infringe by using the software.
49
+
50
+ Noncommercial Purposes
51
+
52
+ Any noncommercial purpose is a permitted purpose.
53
+
54
+ Personal Uses
55
+
56
+ Personal use for research, experiment, and testing for
57
+ the benefit of public knowledge, personal study, private
58
+ entertainment, hobby projects, amateur pursuits, or religious
59
+ observance, without any anticipated commercial application,
60
+ is use for a permitted purpose.
61
+
62
+ Noncommercial Organizations
63
+
64
+ Use by any charitable organization, educational institution,
65
+ public research organization, public safety or health
66
+ organization, environmental protection organization,
67
+ or government institution is use for a permitted purpose
68
+ regardless of the source of funding or obligations resulting
69
+ from the funding.
70
+
71
+ Fair Use
72
+
73
+ You may have "fair use" rights for the software under the
74
+ law. These terms do not limit them.
75
+
76
+ No Other Rights
77
+
78
+ These terms do not allow you to sublicense or transfer any of
79
+ your licenses to anyone else, or prevent the licensor from
80
+ granting licenses to anyone else. These terms do not imply
81
+ any other licenses.
82
+
83
+ Patent Defense
84
+
85
+ If you make any written claim that the software infringes or
86
+ contributes to infringement of any patent, your patent license
87
+ for the software granted under these terms ends immediately. If
88
+ your company makes such a claim, your patent license ends
89
+ immediately for work on behalf of your company.
90
+
91
+ Violations
92
+
93
+ The first time you are notified in writing that you have
94
+ violated any of these terms, or done anything with the software
95
+ not covered by your licenses, your licenses can nonetheless
96
+ continue if you come into full compliance with these terms,
97
+ and take practical steps to correct past violations, within
98
+ 32 days of receiving notice. Otherwise, all your licenses
99
+ end immediately.
100
+
101
+ No Liability
102
+
103
+ AS FAR AS THE LAW ALLOWS, THE SOFTWARE COMES AS IS, WITHOUT
104
+ ANY WARRANTY OR CONDITION, AND THE LICENSOR WILL NOT BE LIABLE
105
+ TO YOU FOR ANY DAMAGES ARISING OUT OF THESE TERMS OR THE USE
106
+ OR NATURE OF THE SOFTWARE, UNDER ANY KIND OF LEGAL CLAIM.
107
+
108
+ Definitions
109
+
110
+ The "licensor" is the individual or entity offering these
111
+ terms, and the "software" is the software the licensor makes
112
+ available under these terms.
113
+
114
+ "You" refers to the individual or entity agreeing to these
115
+ terms.
116
+
117
+ "Your company" is any legal entity, sole proprietorship,
118
+ or other kind of organization that you work for, plus all
119
+ organizations that have control over, are under the control of,
120
+ or are under common control with that organization. "Control"
121
+ means ownership of substantially all the assets of an entity,
122
+ or the power to direct its management and policies by vote,
123
+ contract, or otherwise. Control can be direct or indirect.
124
+
125
+ "Your licenses" are all the licenses granted to you for the
126
+ software under these terms.
127
+
128
+ "Use" means anything you do with the software requiring one
129
+ of your licenses.