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 +6 -0
- kunyi/__main__.py +142 -0
- kunyi/card_types.py +70 -0
- kunyi/deck.py +191 -0
- kunyi/models.py +73 -0
- kunyi/parsers.py +120 -0
- kunyi-0.1.0.dist-info/METADATA +257 -0
- kunyi-0.1.0.dist-info/RECORD +11 -0
- kunyi-0.1.0.dist-info/WHEEL +4 -0
- kunyi-0.1.0.dist-info/entry_points.txt +2 -0
- kunyi-0.1.0.dist-info/licenses/LICENSE +129 -0
kunyi/__init__.py
ADDED
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,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.
|