typstar 1.5.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.
- anki/__init__.py +0 -0
- anki/anki_api.py +152 -0
- anki/config_parser.py +39 -0
- anki/file_handler.py +56 -0
- anki/flashcard.py +92 -0
- anki/main.py +87 -0
- anki/parser.py +180 -0
- anki/typst_compiler.py +90 -0
- typstar-1.5.0.dist-info/METADATA +294 -0
- typstar-1.5.0.dist-info/RECORD +12 -0
- typstar-1.5.0.dist-info/WHEEL +4 -0
- typstar-1.5.0.dist-info/entry_points.txt +3 -0
anki/__init__.py
ADDED
|
File without changes
|
anki/anki_api.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from typing import Iterable, List
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from .flashcard import Flashcard
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def gather_exceptions(coroutines):
|
|
12
|
+
for result in await asyncio.gather(*coroutines, return_exceptions=True):
|
|
13
|
+
if isinstance(result, Exception):
|
|
14
|
+
raise result
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AnkiConnectError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AnkiConnectApi:
|
|
22
|
+
url: str
|
|
23
|
+
api_key: str
|
|
24
|
+
semaphore: asyncio.Semaphore
|
|
25
|
+
|
|
26
|
+
def __init__(self, url: str, api_key: str):
|
|
27
|
+
self.url = url
|
|
28
|
+
self.api_key = api_key
|
|
29
|
+
self.semaphore = asyncio.Semaphore(2) # increase in case Anki implements multithreading
|
|
30
|
+
|
|
31
|
+
async def push_flashcards(self, cards: Iterable[Flashcard], reimport: bool):
|
|
32
|
+
add: dict[str, List[Flashcard]] = defaultdict(list)
|
|
33
|
+
update: dict[str, List[Flashcard]] = defaultdict(list)
|
|
34
|
+
n_add: int = 0
|
|
35
|
+
n_update: int = 0
|
|
36
|
+
|
|
37
|
+
for card in cards:
|
|
38
|
+
if card.is_new():
|
|
39
|
+
add[card.deck].append(card)
|
|
40
|
+
n_add += 1
|
|
41
|
+
else:
|
|
42
|
+
update[card.deck].append(card)
|
|
43
|
+
n_update += 1
|
|
44
|
+
if reimport:
|
|
45
|
+
reimport_cards = await self._check_reimport(update)
|
|
46
|
+
print(f"Found {len(reimport_cards)} flashcards to reimport")
|
|
47
|
+
for card in reimport_cards:
|
|
48
|
+
update[card.deck].remove(card)
|
|
49
|
+
add[card.deck].append(card)
|
|
50
|
+
n_update -= 1
|
|
51
|
+
n_add += 1
|
|
52
|
+
|
|
53
|
+
print(
|
|
54
|
+
f"Pushing {n_add} new flashcards and {n_update} updated flashcards to Anki...",
|
|
55
|
+
flush=True,
|
|
56
|
+
)
|
|
57
|
+
await self._create_required_decks({*add.keys(), *update.keys()})
|
|
58
|
+
await self._add_new_cards(add)
|
|
59
|
+
await gather_exceptions(
|
|
60
|
+
[
|
|
61
|
+
*self._update_cards_requests(add),
|
|
62
|
+
*self._update_cards_requests(update, True),
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
async def _request_api(self, action, **params):
|
|
67
|
+
async with aiohttp.ClientSession() as session:
|
|
68
|
+
data = {
|
|
69
|
+
"action": action,
|
|
70
|
+
"key": self.api_key,
|
|
71
|
+
"params": params,
|
|
72
|
+
"version": 6,
|
|
73
|
+
}
|
|
74
|
+
try:
|
|
75
|
+
async with self.semaphore:
|
|
76
|
+
async with session.post(url=self.url, json=data) as response:
|
|
77
|
+
result = await response.json(encoding="utf-8")
|
|
78
|
+
if err := result["error"]:
|
|
79
|
+
raise AnkiConnectError(err)
|
|
80
|
+
return result["result"]
|
|
81
|
+
except aiohttp.ClientError as e:
|
|
82
|
+
raise AnkiConnectError(f"Could not connect to Anki: {e}")
|
|
83
|
+
|
|
84
|
+
async def _update_note_model(self, card: Flashcard):
|
|
85
|
+
await self._request_api("updateNoteModel", note=card.as_anki_model())
|
|
86
|
+
|
|
87
|
+
async def _store_media(self, card):
|
|
88
|
+
await self._request_api(
|
|
89
|
+
"storeMediaFile",
|
|
90
|
+
filename=card.svg_filename(True),
|
|
91
|
+
data=base64.b64encode(card.svg_front).decode(),
|
|
92
|
+
)
|
|
93
|
+
await self._request_api(
|
|
94
|
+
"storeMediaFile",
|
|
95
|
+
filename=card.svg_filename(False),
|
|
96
|
+
data=base64.b64encode(card.svg_back).decode(),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def _change_deck(self, deck: str, cards: List[int]):
|
|
100
|
+
await self._request_api("changeDeck", deck=deck, cards=cards)
|
|
101
|
+
|
|
102
|
+
async def _add_new_cards(self, cards_map: dict[str, List[Flashcard]]):
|
|
103
|
+
notes: List[Flashcard] = []
|
|
104
|
+
notes_data: List[dict] = []
|
|
105
|
+
for cards in cards_map.values():
|
|
106
|
+
for card in cards:
|
|
107
|
+
data = {
|
|
108
|
+
"deckName": card.deck,
|
|
109
|
+
"options": {
|
|
110
|
+
"allowDuplicate": True, # won't work with svgs
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
data.update(card.as_anki_model(True))
|
|
114
|
+
notes.append(card)
|
|
115
|
+
notes_data.append(data)
|
|
116
|
+
result = await self._request_api("addNotes", notes=notes_data)
|
|
117
|
+
for idx, note_id in enumerate(result):
|
|
118
|
+
notes[idx].update_id(note_id)
|
|
119
|
+
|
|
120
|
+
async def _create_required_decks(self, required: Iterable[str]):
|
|
121
|
+
existing = await self._request_api("deckNamesAndIds")
|
|
122
|
+
requests = []
|
|
123
|
+
for deck in required:
|
|
124
|
+
if deck not in existing:
|
|
125
|
+
requests.append(self._request_api("createDeck", deck=deck))
|
|
126
|
+
await gather_exceptions(requests)
|
|
127
|
+
|
|
128
|
+
async def _check_reimport(self, cards_map: dict[str, List[Flashcard]]) -> List[Flashcard]:
|
|
129
|
+
cards = []
|
|
130
|
+
for cs in cards_map.values():
|
|
131
|
+
cards.extend(cs)
|
|
132
|
+
if not cards:
|
|
133
|
+
return []
|
|
134
|
+
existing = await self._request_api(
|
|
135
|
+
"findNotes", query=f"nid:{','.join([str(c.note_id) for c in cards])}"
|
|
136
|
+
)
|
|
137
|
+
return [c for c in cards if c.note_id not in existing]
|
|
138
|
+
|
|
139
|
+
def _update_cards_requests(
|
|
140
|
+
self, cards_map: dict[str, List[Flashcard]], update_deck: bool = True
|
|
141
|
+
):
|
|
142
|
+
requests = []
|
|
143
|
+
for deck, cards in cards_map.items():
|
|
144
|
+
card_ids = []
|
|
145
|
+
for card in cards:
|
|
146
|
+
requests.append(self._update_note_model(card))
|
|
147
|
+
requests.append(self._store_media(card))
|
|
148
|
+
if update_deck:
|
|
149
|
+
card_ids.append(card.note_id)
|
|
150
|
+
if card_ids:
|
|
151
|
+
requests.append(self._change_deck(deck, card_ids))
|
|
152
|
+
return requests
|
anki/config_parser.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from functools import cache
|
|
3
|
+
from glob import glob
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RecursiveConfigParser:
|
|
8
|
+
dir: Path
|
|
9
|
+
targets: set[str]
|
|
10
|
+
results: dict[str, dict[Path, str]]
|
|
11
|
+
|
|
12
|
+
def __init__(self, dir, targets, recursive=True):
|
|
13
|
+
self.dir = dir
|
|
14
|
+
self.targets = set(targets)
|
|
15
|
+
self.results = defaultdict(dict)
|
|
16
|
+
self._parse_dirs(recursive)
|
|
17
|
+
|
|
18
|
+
def _parse_dirs(self, recursive=True):
|
|
19
|
+
files = []
|
|
20
|
+
for target in self.targets:
|
|
21
|
+
if recursive:
|
|
22
|
+
dir = f"{self.dir}/**/{target}"
|
|
23
|
+
else:
|
|
24
|
+
dir = f"{self.dir}/{target}"
|
|
25
|
+
files.extend(glob(dir, include_hidden=target.startswith("."), recursive=recursive))
|
|
26
|
+
for file in files:
|
|
27
|
+
file = Path(file)
|
|
28
|
+
if file.name in self.targets:
|
|
29
|
+
self.results[file.name][file.parent] = file.read_text(encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
@cache
|
|
32
|
+
def get_config(self, path: Path, target) -> str | None:
|
|
33
|
+
root_parent = self.dir.parent.resolve()
|
|
34
|
+
path = Path(path.resolve())
|
|
35
|
+
target_results = self.results[target]
|
|
36
|
+
while path != root_parent:
|
|
37
|
+
if result := target_results.get(path):
|
|
38
|
+
return result
|
|
39
|
+
path = path.parent
|
anki/file_handler.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
import tree_sitter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FileHandler:
|
|
9
|
+
file_path: Path
|
|
10
|
+
file_content: List[bytes]
|
|
11
|
+
|
|
12
|
+
def __init__(self, path: Path):
|
|
13
|
+
self.file_path = path
|
|
14
|
+
self.read()
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def directory_path(self) -> Path:
|
|
18
|
+
return self.file_path.parent
|
|
19
|
+
|
|
20
|
+
def get_bytes(self) -> bytes:
|
|
21
|
+
return b"".join(self.file_content)
|
|
22
|
+
|
|
23
|
+
def get_file_hash(self) -> str:
|
|
24
|
+
return hashlib.md5(self.get_bytes(), usedforsecurity=False).hexdigest()
|
|
25
|
+
|
|
26
|
+
def get_node_content(self, node: tree_sitter.Node, remove_outer=False) -> str:
|
|
27
|
+
content = (
|
|
28
|
+
b"".join(self.file_content[node.start_point.row : node.end_point.row + 1])[
|
|
29
|
+
node.start_point.column : -(
|
|
30
|
+
len(self.file_content[node.end_point.row]) - node.end_point.column
|
|
31
|
+
)
|
|
32
|
+
]
|
|
33
|
+
).decode()
|
|
34
|
+
return content[1:-1] if remove_outer else content
|
|
35
|
+
|
|
36
|
+
def update_node_content(self, node: tree_sitter.Node, value):
|
|
37
|
+
new_lines = self.file_content[: node.start_point.row]
|
|
38
|
+
first_line = self.file_content[node.start_point.row][: node.start_point.column]
|
|
39
|
+
last_line = self.file_content[node.end_point.row][node.end_point.column :]
|
|
40
|
+
new_lines.extend(
|
|
41
|
+
(
|
|
42
|
+
line + b"\n"
|
|
43
|
+
for line in (first_line + str(value).encode() + last_line).split(b"\n")
|
|
44
|
+
if line != b""
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
new_lines.extend(self.file_content[node.end_point.row + 1 :])
|
|
48
|
+
self.file_content = new_lines
|
|
49
|
+
|
|
50
|
+
def read(self):
|
|
51
|
+
with self.file_path.open("rb") as f:
|
|
52
|
+
self.file_content = f.readlines()
|
|
53
|
+
|
|
54
|
+
def write(self):
|
|
55
|
+
with self.file_path.open("wb") as f:
|
|
56
|
+
f.writelines(self.file_content)
|
anki/flashcard.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import html
|
|
2
|
+
|
|
3
|
+
import tree_sitter
|
|
4
|
+
|
|
5
|
+
from .file_handler import FileHandler
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Flashcard:
|
|
9
|
+
note_id: int
|
|
10
|
+
front: str
|
|
11
|
+
back: str
|
|
12
|
+
deck: str
|
|
13
|
+
id_updated: bool
|
|
14
|
+
|
|
15
|
+
preamble: str | None
|
|
16
|
+
file_handler: FileHandler
|
|
17
|
+
|
|
18
|
+
note_id_node: tree_sitter.Node
|
|
19
|
+
front_node: tree_sitter.Node
|
|
20
|
+
back_node: tree_sitter.Node
|
|
21
|
+
|
|
22
|
+
svg_front: bytes
|
|
23
|
+
svg_back: bytes
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
front: str,
|
|
28
|
+
back: str,
|
|
29
|
+
deck: str | None,
|
|
30
|
+
note_id: int,
|
|
31
|
+
preamble: str | None,
|
|
32
|
+
file_handler: FileHandler,
|
|
33
|
+
):
|
|
34
|
+
if deck is None:
|
|
35
|
+
deck = "Default"
|
|
36
|
+
if not note_id:
|
|
37
|
+
note_id = 0
|
|
38
|
+
self.front = front
|
|
39
|
+
self.back = back
|
|
40
|
+
self.deck = deck
|
|
41
|
+
self.note_id = note_id
|
|
42
|
+
self.preamble = preamble
|
|
43
|
+
self.file_handler = file_handler
|
|
44
|
+
self.id_updated = False
|
|
45
|
+
|
|
46
|
+
def __str__(self):
|
|
47
|
+
return f"Flashcard(id={self.note_id}, front={self.front})"
|
|
48
|
+
|
|
49
|
+
def as_typst(self, front: bool) -> str:
|
|
50
|
+
return f"#flashcard({self.note_id})[{self.front if front else ''}][{self.back if not front else ''}]"
|
|
51
|
+
|
|
52
|
+
def as_html(self, front: bool) -> str:
|
|
53
|
+
safe_front = html.escape(self.front)
|
|
54
|
+
safe_back = html.escape(self.back)
|
|
55
|
+
prefix = f"<p hidden>{safe_front}: {safe_back}{' ' * 10}</p>" # indexable via anki search
|
|
56
|
+
image = f'<img src="{self.svg_filename(front)}" />'
|
|
57
|
+
return prefix + image
|
|
58
|
+
|
|
59
|
+
def as_anki_model(self, tmp: bool = False) -> dict:
|
|
60
|
+
model = {
|
|
61
|
+
"modelName": "Basic",
|
|
62
|
+
"fields": {
|
|
63
|
+
"Front": f"tmp typst: {self.front}" if tmp else self.as_html(True),
|
|
64
|
+
"Back": f"tmp typst: {self.back}" if tmp else self.as_html(False),
|
|
65
|
+
},
|
|
66
|
+
"tags": ["typst"],
|
|
67
|
+
}
|
|
68
|
+
if not self.is_new():
|
|
69
|
+
model["id"] = self.note_id
|
|
70
|
+
return model
|
|
71
|
+
|
|
72
|
+
def svg_filename(self, front: bool) -> str:
|
|
73
|
+
return f"typst_{self.note_id}_{'front' if front else 'back'}.svg"
|
|
74
|
+
|
|
75
|
+
def is_new(self) -> bool:
|
|
76
|
+
return self.note_id == 0 or self.note_id is None
|
|
77
|
+
|
|
78
|
+
def set_ts_nodes(
|
|
79
|
+
self, front: tree_sitter.Node, back: tree_sitter.Node, note_id: tree_sitter.Node
|
|
80
|
+
):
|
|
81
|
+
self.front_node = front
|
|
82
|
+
self.back_node = back
|
|
83
|
+
self.note_id_node = note_id
|
|
84
|
+
|
|
85
|
+
def update_id(self, value: int):
|
|
86
|
+
if self.note_id != value:
|
|
87
|
+
self.note_id = value
|
|
88
|
+
self.id_updated = True
|
|
89
|
+
|
|
90
|
+
def set_svgs(self, front, back):
|
|
91
|
+
self.svg_front = front
|
|
92
|
+
self.svg_back = back
|
anki/main.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from typing_extensions import Annotated
|
|
7
|
+
|
|
8
|
+
from anki.anki_api import AnkiConnectApi
|
|
9
|
+
from anki.parser import FlashcardParser
|
|
10
|
+
from anki.typst_compiler import TypstCompiler
|
|
11
|
+
|
|
12
|
+
cli = typer.Typer(name="typstar-anki")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def export_flashcards(
|
|
16
|
+
root_dir, force_scan, clear_cache, reimport, typst_cmd, anki_url, anki_key
|
|
17
|
+
):
|
|
18
|
+
parser = FlashcardParser()
|
|
19
|
+
compiler = TypstCompiler(root_dir, typst_cmd)
|
|
20
|
+
api = AnkiConnectApi(anki_url, anki_key)
|
|
21
|
+
|
|
22
|
+
# parse flashcards
|
|
23
|
+
if clear_cache:
|
|
24
|
+
parser.clear_file_hashes()
|
|
25
|
+
flashcards = parser.parse_directory(root_dir, force_scan)
|
|
26
|
+
|
|
27
|
+
# async typst compilation
|
|
28
|
+
await compiler.compile_flashcards(flashcards)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
# async anki push
|
|
32
|
+
await api.push_flashcards(flashcards, reimport)
|
|
33
|
+
finally:
|
|
34
|
+
# write id updates to files
|
|
35
|
+
parser.update_ids_in_source()
|
|
36
|
+
parser.save_file_hashes()
|
|
37
|
+
print("Done", flush=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@cli.command()
|
|
41
|
+
def cmd(
|
|
42
|
+
root_dir: Annotated[
|
|
43
|
+
Path,
|
|
44
|
+
typer.Option(
|
|
45
|
+
help="Directory scanned for flashcards and passed over to typst compile command"
|
|
46
|
+
),
|
|
47
|
+
] = Path(os.getcwd()),
|
|
48
|
+
force_scan: Annotated[
|
|
49
|
+
Path | None,
|
|
50
|
+
typer.Option(
|
|
51
|
+
help="File/directory to scan for flashcards while ignoring stored "
|
|
52
|
+
"file hashes (e.g. on preamble change)"
|
|
53
|
+
),
|
|
54
|
+
] = None,
|
|
55
|
+
clear_cache: Annotated[
|
|
56
|
+
bool,
|
|
57
|
+
typer.Option(
|
|
58
|
+
help="Clear all stored file hashes (more aggressive than force-scan "
|
|
59
|
+
"as it clears hashes regardless of their path)"
|
|
60
|
+
),
|
|
61
|
+
] = False,
|
|
62
|
+
reimport: Annotated[
|
|
63
|
+
bool,
|
|
64
|
+
typer.Option(
|
|
65
|
+
help="Instead of throwing an error, also add flashcards that have already been assigned an id "
|
|
66
|
+
"but are not present in Anki. The assigned id will be updated in the source code."
|
|
67
|
+
),
|
|
68
|
+
] = False,
|
|
69
|
+
typst_cmd: Annotated[
|
|
70
|
+
str, typer.Option(help="Typst command used for flashcard compilation")
|
|
71
|
+
] = "typst",
|
|
72
|
+
anki_url: Annotated[str, typer.Option(help="Url for Anki-Connect")] = "http://127.0.0.1:8765",
|
|
73
|
+
anki_key: Annotated[str | None, typer.Option(help="Api key for Anki-Connect")] = None,
|
|
74
|
+
):
|
|
75
|
+
asyncio.run(
|
|
76
|
+
export_flashcards(
|
|
77
|
+
root_dir, force_scan, clear_cache, reimport, typst_cmd, anki_url, anki_key
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main():
|
|
83
|
+
typer.run(cmd)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
main()
|
anki/parser.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from glob import glob
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Tuple
|
|
6
|
+
|
|
7
|
+
import appdirs
|
|
8
|
+
from tree_sitter import Language, Parser, Query, QueryCursor
|
|
9
|
+
from tree_sitter_typst import language as get_typst_language
|
|
10
|
+
|
|
11
|
+
from .config_parser import RecursiveConfigParser
|
|
12
|
+
from .file_handler import FileHandler
|
|
13
|
+
from .flashcard import Flashcard
|
|
14
|
+
|
|
15
|
+
ts_flashcard_query = """
|
|
16
|
+
(call
|
|
17
|
+
item: [
|
|
18
|
+
(call
|
|
19
|
+
item: (call
|
|
20
|
+
item: (ident) @fncall
|
|
21
|
+
(group
|
|
22
|
+
(number) @id))
|
|
23
|
+
(content) @front)
|
|
24
|
+
(call
|
|
25
|
+
item: (ident) @fncall
|
|
26
|
+
(group
|
|
27
|
+
(number) @id
|
|
28
|
+
(string) @front))
|
|
29
|
+
]
|
|
30
|
+
(#eq? @fncall "flashcard")
|
|
31
|
+
((content) @back
|
|
32
|
+
) @flashcard)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
ts_deck_query = """
|
|
36
|
+
((comment) @deck)
|
|
37
|
+
"""
|
|
38
|
+
deck_regex = re.compile(r"\W+ANKI:\s*([\S ]*)")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FlashcardParser:
|
|
42
|
+
typst_language: Language
|
|
43
|
+
typst_parser: Parser
|
|
44
|
+
flashcard_query_cursor: QueryCursor
|
|
45
|
+
deck_query_cursor: QueryCursor
|
|
46
|
+
|
|
47
|
+
file_handlers: List[tuple[FileHandler, List[Flashcard]]]
|
|
48
|
+
file_hashes: dict[str, str]
|
|
49
|
+
file_hashes_store_path: Path = Path(appdirs.user_state_dir("typstar") + "/file_hashes.json")
|
|
50
|
+
|
|
51
|
+
def __init__(self):
|
|
52
|
+
self.typst_language = Language(get_typst_language())
|
|
53
|
+
self.typst_parser = Parser(self.typst_language)
|
|
54
|
+
self.flashcard_query_cursor = QueryCursor(Query(self.typst_language, ts_flashcard_query))
|
|
55
|
+
self.deck_query_cursor = QueryCursor(Query(self.typst_language, ts_deck_query))
|
|
56
|
+
self.file_handlers = []
|
|
57
|
+
self._load_file_hashes()
|
|
58
|
+
|
|
59
|
+
def _parse_file(
|
|
60
|
+
self, file: FileHandler, preamble: str | None, default_deck: str | None
|
|
61
|
+
) -> List[Flashcard]:
|
|
62
|
+
cards = []
|
|
63
|
+
tree = self.typst_parser.parse(file.get_bytes(), encoding="utf8")
|
|
64
|
+
card_captures = self.flashcard_query_cursor.captures(tree.root_node)
|
|
65
|
+
if not card_captures:
|
|
66
|
+
return cards
|
|
67
|
+
deck_captures = self.deck_query_cursor.captures(tree.root_node)
|
|
68
|
+
|
|
69
|
+
def row_compare(node):
|
|
70
|
+
return node.start_point.row
|
|
71
|
+
|
|
72
|
+
card_captures["id"].sort(key=row_compare)
|
|
73
|
+
card_captures["front"].sort(key=row_compare)
|
|
74
|
+
card_captures["back"].sort(key=row_compare)
|
|
75
|
+
|
|
76
|
+
deck_refs: List[Tuple[int, str | None]] = []
|
|
77
|
+
deck_refs_idx = -1
|
|
78
|
+
current_deck = default_deck
|
|
79
|
+
if deck_captures:
|
|
80
|
+
deck_captures["deck"].sort(key=row_compare)
|
|
81
|
+
for comment in deck_captures["deck"]:
|
|
82
|
+
if match := deck_regex.match(file.get_node_content(comment)):
|
|
83
|
+
deck_refs.append(
|
|
84
|
+
(
|
|
85
|
+
comment.start_point.row,
|
|
86
|
+
None if match[1].isspace() else match[1],
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
for note_id, front, back in zip(
|
|
91
|
+
card_captures["id"], card_captures["front"], card_captures["back"]
|
|
92
|
+
):
|
|
93
|
+
while (
|
|
94
|
+
deck_refs_idx < len(deck_refs) - 1
|
|
95
|
+
and back.end_point.row >= deck_refs[deck_refs_idx + 1][0]
|
|
96
|
+
):
|
|
97
|
+
deck_refs_idx += 1
|
|
98
|
+
current_deck = deck_refs[deck_refs_idx][1]
|
|
99
|
+
|
|
100
|
+
card = Flashcard(
|
|
101
|
+
file.get_node_content(front, True),
|
|
102
|
+
file.get_node_content(back, True),
|
|
103
|
+
current_deck,
|
|
104
|
+
int(file.get_node_content(note_id)),
|
|
105
|
+
preamble,
|
|
106
|
+
file,
|
|
107
|
+
)
|
|
108
|
+
card.set_ts_nodes(front, back, note_id)
|
|
109
|
+
cards.append(card)
|
|
110
|
+
return cards
|
|
111
|
+
|
|
112
|
+
def parse_directory(self, root_dir: Path, force_scan: Path | None = None):
|
|
113
|
+
flashcards = []
|
|
114
|
+
single_file = None
|
|
115
|
+
is_force_scan = force_scan is not None
|
|
116
|
+
if is_force_scan:
|
|
117
|
+
if force_scan.is_file():
|
|
118
|
+
single_file = force_scan
|
|
119
|
+
scan_dir = force_scan.parent
|
|
120
|
+
else:
|
|
121
|
+
scan_dir = force_scan
|
|
122
|
+
else:
|
|
123
|
+
scan_dir = root_dir
|
|
124
|
+
|
|
125
|
+
print(
|
|
126
|
+
f"Parsing flashcards in {scan_dir if single_file is None else single_file} ...",
|
|
127
|
+
flush=True,
|
|
128
|
+
)
|
|
129
|
+
configs = RecursiveConfigParser(
|
|
130
|
+
root_dir, {".anki", ".anki.typ"}, recursive=single_file is None
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
for file in glob(f"{scan_dir}/**/**.typ", recursive=True):
|
|
134
|
+
file = Path(file)
|
|
135
|
+
if single_file is not None and file != single_file:
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
fh = FileHandler(file)
|
|
139
|
+
file_changed = self._hash_changed(fh)
|
|
140
|
+
if is_force_scan or file_changed:
|
|
141
|
+
cards = self._parse_file(
|
|
142
|
+
fh, configs.get_config(file, ".anki.typ"), configs.get_config(file, ".anki")
|
|
143
|
+
)
|
|
144
|
+
self.file_handlers.append((fh, cards))
|
|
145
|
+
flashcards.extend(cards)
|
|
146
|
+
return flashcards
|
|
147
|
+
|
|
148
|
+
def _hash_changed(self, file: FileHandler) -> bool:
|
|
149
|
+
file_hash = file.get_file_hash()
|
|
150
|
+
cached = self.file_hashes.get(str(file.file_path))
|
|
151
|
+
self.file_hashes[str(file.file_path)] = file_hash
|
|
152
|
+
return file_hash != cached
|
|
153
|
+
|
|
154
|
+
def _load_file_hashes(self):
|
|
155
|
+
self.file_hashes_store_path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
self.file_hashes_store_path.touch()
|
|
157
|
+
content = self.file_hashes_store_path.read_text()
|
|
158
|
+
if content:
|
|
159
|
+
self.file_hashes = json.loads(content)
|
|
160
|
+
else:
|
|
161
|
+
self.file_hashes = {}
|
|
162
|
+
|
|
163
|
+
def save_file_hashes(self):
|
|
164
|
+
self.file_hashes_store_path.write_text(json.dumps(self.file_hashes))
|
|
165
|
+
|
|
166
|
+
def clear_file_hashes(self):
|
|
167
|
+
self.file_hashes = {}
|
|
168
|
+
self.save_file_hashes()
|
|
169
|
+
|
|
170
|
+
def update_ids_in_source(self):
|
|
171
|
+
print("Updating ids in source...", flush=True)
|
|
172
|
+
for fh, cards in self.file_handlers:
|
|
173
|
+
file_updated = False
|
|
174
|
+
for c in cards:
|
|
175
|
+
if c.id_updated:
|
|
176
|
+
fh.update_node_content(c.note_id_node, c.note_id)
|
|
177
|
+
file_updated = True
|
|
178
|
+
if file_updated:
|
|
179
|
+
fh.write()
|
|
180
|
+
self.file_hashes[str(fh.file_path)] = fh.get_file_hash()
|
anki/typst_compiler.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import random
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List
|
|
8
|
+
|
|
9
|
+
from .flashcard import Flashcard
|
|
10
|
+
|
|
11
|
+
default_preamble = """
|
|
12
|
+
#set text(size: 20pt)
|
|
13
|
+
#set page(width: auto, height: auto, margin: (rest: 8pt))
|
|
14
|
+
#let flashcard(id, front, back) = {
|
|
15
|
+
strong(front)
|
|
16
|
+
[\\ ]
|
|
17
|
+
back
|
|
18
|
+
}
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TypstCompilationError(ValueError):
|
|
23
|
+
regex = re.compile(r"\nerror: ")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TypstCompiler:
|
|
27
|
+
preamble: str
|
|
28
|
+
typst_cmd: str
|
|
29
|
+
typst_root_dir: Path
|
|
30
|
+
max_processes: int
|
|
31
|
+
|
|
32
|
+
def __init__(self, typst_root_dir: Path, typst_cmd: str):
|
|
33
|
+
self.typst_cmd = typst_cmd
|
|
34
|
+
self.typst_root_dir = typst_root_dir
|
|
35
|
+
n_cpus = os.cpu_count()
|
|
36
|
+
if n_cpus is None:
|
|
37
|
+
self.max_processes = 10
|
|
38
|
+
else:
|
|
39
|
+
self.max_processes = round(1.5 * n_cpus)
|
|
40
|
+
|
|
41
|
+
async def _compile(self, src: str, directory: Path) -> bytes:
|
|
42
|
+
tmp_path = f"{directory}/tmp_{random.randint(1, 1000000000)}.typ"
|
|
43
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
44
|
+
f.write(src)
|
|
45
|
+
proc = await asyncio.create_subprocess_exec(
|
|
46
|
+
self.typst_cmd,
|
|
47
|
+
"compile",
|
|
48
|
+
tmp_path,
|
|
49
|
+
"-",
|
|
50
|
+
"--root",
|
|
51
|
+
str(self.typst_root_dir),
|
|
52
|
+
"--format",
|
|
53
|
+
"svg",
|
|
54
|
+
stdout=asyncio.subprocess.PIPE,
|
|
55
|
+
stderr=asyncio.subprocess.PIPE,
|
|
56
|
+
)
|
|
57
|
+
stdout, stderr = await proc.communicate()
|
|
58
|
+
os.remove(tmp_path)
|
|
59
|
+
if stderr:
|
|
60
|
+
err = bytes.decode(stderr, encoding="utf-8")
|
|
61
|
+
if TypstCompilationError.regex.search("\n" + err):
|
|
62
|
+
raise TypstCompilationError(err)
|
|
63
|
+
else:
|
|
64
|
+
print(f"Typst compilation warning:\n{err}", file=sys.stderr, flush=True)
|
|
65
|
+
return stdout
|
|
66
|
+
|
|
67
|
+
async def _compile_flashcard(self, card: Flashcard):
|
|
68
|
+
preamble = default_preamble if card.preamble is None else card.preamble
|
|
69
|
+
front = await self._compile(
|
|
70
|
+
preamble + "\n" + card.as_typst(True), card.file_handler.directory_path
|
|
71
|
+
)
|
|
72
|
+
back = await self._compile(
|
|
73
|
+
preamble + "\n" + card.as_typst(False), card.file_handler.directory_path
|
|
74
|
+
)
|
|
75
|
+
card.set_svgs(front, back)
|
|
76
|
+
|
|
77
|
+
async def compile_flashcards(self, cards: List[Flashcard]):
|
|
78
|
+
print(f"Compiling {len(cards)} flashcards...", flush=True)
|
|
79
|
+
semaphore = asyncio.Semaphore(self.max_processes)
|
|
80
|
+
|
|
81
|
+
async def compile_coro(card):
|
|
82
|
+
async with semaphore:
|
|
83
|
+
return await self._compile_flashcard(card)
|
|
84
|
+
|
|
85
|
+
results = await asyncio.gather(
|
|
86
|
+
*(compile_coro(card) for card in cards), return_exceptions=True
|
|
87
|
+
)
|
|
88
|
+
for result in results:
|
|
89
|
+
if isinstance(result, Exception):
|
|
90
|
+
raise result
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: typstar
|
|
3
|
+
Version: 1.5.0
|
|
4
|
+
Summary: Neovim plugin for efficient note taking in Typst
|
|
5
|
+
Author: arne314
|
|
6
|
+
Requires-Dist: aiohttp>=3.13.3
|
|
7
|
+
Requires-Dist: appdirs>=1.4.4
|
|
8
|
+
Requires-Dist: tree-sitter==0.25.2
|
|
9
|
+
Requires-Dist: tree-sitter-typst
|
|
10
|
+
Requires-Dist: typer>=0.24.1
|
|
11
|
+
Requires-Dist: typing-extensions>=4.15.0
|
|
12
|
+
Requires-Python: >=3.11.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Typstar
|
|
16
|
+
Neovim plugin for efficient (mathematical) note taking in Typst
|
|
17
|
+
|
|
18
|
+
See changes in [`CHANGELOG.md`](./CHANGELOG.md)
|
|
19
|
+
|
|
20
|
+
[](https://github.com/arne314/typstar/actions/workflows/weekly.yml)
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
- Powerful autosnippets using [LuaSnip](https://github.com/L3MON4D3/LuaSnip/) and [Tree-sitter](https://tree-sitter.github.io/) (inspired by [fastex.nvim](https://github.com/lentilus/fastex.nvim))
|
|
24
|
+
- Easy insertion of drawings using [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) or [Rnote](https://github.com/flxzt/rnote)
|
|
25
|
+
- Export of [Anki](https://apps.ankiweb.net/) flashcards \[No Neovim required\]
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
### Snippets
|
|
30
|
+
Use `:TypstarToggleSnippets` to toggle all snippets at any time.
|
|
31
|
+
To efficiently navigate insert nodes and avoid overlapping ones,
|
|
32
|
+
use `:TypstarSmartJump` and `:TypstarSmartJumpBack`.
|
|
33
|
+
Available snippets can mostly be intuitively derived from [here](././lua/typstar/snippets), they include:
|
|
34
|
+
|
|
35
|
+
Universal snippets:
|
|
36
|
+
- Alphanumeric characters: `:<char>` → `$<char>$ ` in markup (e.g. `:X` → `$X$ `, `:5` → `$5$ `)
|
|
37
|
+
- Greek letters: `;<latin>` → `<greek>` in math and `$<greek>$ ` in markup (e.g. `;a` → `alpha`/`$alpha$ `)
|
|
38
|
+
- Common indices (numbers and letters `i-n`): `<letter><index> ` → `<letter>_<index> ` in math and `$<letter>$ <index> ` → `$<letter>_<index>$ ` in markup (e.g `A314 ` → `A_314 `, `$alpha$ n ` → `$alpha_n$ `, `$F$ n,` → `$F_n$, `, `$F$ n.` → `$F_n$.`)
|
|
39
|
+
- Primes: `$<letter>$ ' ` → `$<letter>'$ ` and in combination with index and punctuation like above (e.g. `$phi$ ' ` → `$phi'$ `, `$phi$ 5'` → `$phi'_5$ `, `$f$ '5.` → `$f'_5$.`, `f'5,` → `f'_5, `)
|
|
40
|
+
|
|
41
|
+
You can find a complete map of latin to greek letters including reasons for the less intuitive ones [here](./lua/typstar/snippets/letters.lua).
|
|
42
|
+
Note that some greek letters have multiple latin ones mapped to them.
|
|
43
|
+
|
|
44
|
+
Markup snippets:
|
|
45
|
+
- Begin inline math with `kk` and multiline math with `dm`
|
|
46
|
+
- [Markup shorthands](./lua/typstar/snippets/markup.lua) (e.g. `HIG` → `#highlight[<cursor>]`, `IMP` → `$==>$ `)
|
|
47
|
+
- [ctheorems shorthands](./lua/typstar/snippets/markup.lua) (e.g. `tem` → empty theorem, `exa` → empty example)
|
|
48
|
+
- [Flashcards](#anki): `fla` and `flA`
|
|
49
|
+
- All above snippets support visual mode via the [selection key](#installation)
|
|
50
|
+
|
|
51
|
+
Math snippets:
|
|
52
|
+
- [Many shorthands](./lua/typstar/snippets/math.lua) for mathematical expressions
|
|
53
|
+
- Series of numbered letters: `<letter> <z/o>t<optional last index> ` → `<letter>_<0/1>, <letter>_<1/2>, ... ` (e.g. `a ot ` → `a_1, a_2, ... `, `a zt4 ` → `a_0, a_1, a_2, a_3, a_4 `, `alpha otk ` → `alpha_1, alpha_2, ..., alpha_k `, `oti ` → `1, 2, ..., i `)
|
|
54
|
+
- Wrapping of any mathematical expression (see [operations](./lua/typstar/snippets/visual.lua), works nested, multiline and in visual mode via the [selection key](#installation)): `<expression><operation>` → `<operation>(<expression>)` (e.g. `(a^2+b^2)rt` → `sqrt(a^2+b^2)`, `lambdatd` → `tilde(lambda)`, `(1+1)sQ` → `[1+1]`, `(1+1)sq` → `[(1+1)]`)
|
|
55
|
+
- Simple functions: `fo<value> ` → `f(<value>) ` (e.g. `fox ` → `f(x) `, `ao5 ` → `a(5) `)
|
|
56
|
+
- Matrices: `<size>ma` and `<size>lma` (e.g. `23ma` → 2x3 matrix)
|
|
57
|
+
|
|
58
|
+
Note that you can [customize](#custom-snippets) (enable, disable and modify) every snippet.
|
|
59
|
+
|
|
60
|
+
### Excalidraw/Rnote
|
|
61
|
+
- Use `:TypstarInsertExcalidraw`/`:TypstarInsertRnote` to
|
|
62
|
+
create a new drawing using the [configured](#configuration) template,
|
|
63
|
+
insert a figure displaying it and open it in Obsidian/Rnote.
|
|
64
|
+
- To open an inserted drawing in Obsidian/Rnote,
|
|
65
|
+
simply run `:TypstarOpenDrawing` (or `:TypstarOpenExcalidraw`/`:TypstarOpenRnote` if you are using the same file extension for both)
|
|
66
|
+
while your cursor is on a line referencing the drawing.
|
|
67
|
+
|
|
68
|
+
### Anki
|
|
69
|
+
Use the `flA` snippet to create a new flashcard
|
|
70
|
+
```typst
|
|
71
|
+
#flashcard(0, "My first flashcard")[
|
|
72
|
+
Typst is awesome $a^2+b^2=c^2$
|
|
73
|
+
]
|
|
74
|
+
```
|
|
75
|
+
or the `fla` snippet to add a more complex front
|
|
76
|
+
```typst
|
|
77
|
+
#flashcard(0)[I love Typst $pi$][
|
|
78
|
+
This is the back of my second flashcard
|
|
79
|
+
]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
To render the flashcard in your document as well add some code like this
|
|
83
|
+
```typst
|
|
84
|
+
#let flashcard(id, front, back) = {
|
|
85
|
+
strong(front)
|
|
86
|
+
[\ ]
|
|
87
|
+
back
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- Add a comment like `// ANKI: MY::DECK` to your document to set a deck used for all flashcards after this comment (You can use multiple decks per file)
|
|
92
|
+
- Add a file named `.anki` containing a deck name to define a default deck on a directory base
|
|
93
|
+
- Add a file named `.anki.typ` to define a preamble on a directory base. You can find the default preamble [here](./src/anki/typst_compiler.py).
|
|
94
|
+
- Tip: Despite the use of SVGs you can still search your flashcards in Anki as the typst source is added into an invisible html paragraph
|
|
95
|
+
|
|
96
|
+
#### Neovim
|
|
97
|
+
- Use `:TypstarAnkiScan` to scan the current nvim working directory and compile all flashcards in its context, unchanged files will be ignored
|
|
98
|
+
- Use `:TypstarAnkiForce` to force compilation of all flashcards in the current working directory even if the files haven't changed since the last scan (e.g. on preamble change)
|
|
99
|
+
- Use `:TypstarAnkiForceCurrent` to force compilation of all flashcards in the file currently edited
|
|
100
|
+
- Use `:TypstarAnkiReimport` to also add flashcards that have already been assigned an id but are not currently
|
|
101
|
+
present in Anki
|
|
102
|
+
- Use `:TypstarAnkiForceReimport` and `:TypstarAnkiForceCurrentReimport` to combine features accordingly
|
|
103
|
+
|
|
104
|
+
#### Standalone
|
|
105
|
+
- Run `typstar-anki --help` to show the available options
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
## Installation
|
|
109
|
+
Install the plugin in Neovim and run the plugin setup.
|
|
110
|
+
You can install and run a demo installation using [Nix](#in-a-nix-flake-optional)).
|
|
111
|
+
```lua
|
|
112
|
+
require('typstar').setup({ -- depending on your neovim plugin system
|
|
113
|
+
-- your typstar config goes here
|
|
114
|
+
})
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
<details>
|
|
118
|
+
<summary>Example lazy.nvim config</summary>
|
|
119
|
+
|
|
120
|
+
```lua
|
|
121
|
+
{
|
|
122
|
+
"arne314/typstar",
|
|
123
|
+
dependencies = {
|
|
124
|
+
"L3MON4D3/LuaSnip",
|
|
125
|
+
},
|
|
126
|
+
ft = { "typst" },
|
|
127
|
+
keys = {
|
|
128
|
+
{
|
|
129
|
+
"<M-t>",
|
|
130
|
+
"<Cmd>TypstarToggleSnippets<CR>",
|
|
131
|
+
mode = { "n", "i" },
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"<M-j>",
|
|
135
|
+
"<Cmd>TypstarSmartJump<CR>",
|
|
136
|
+
mode = { "s", "i" },
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"<M-k>",
|
|
140
|
+
"<Cmd>TypstarSmartJumpBack<CR>",
|
|
141
|
+
mode = { "s", "i" },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
config = function()
|
|
145
|
+
local typstar = require("typstar")
|
|
146
|
+
typstar.setup({
|
|
147
|
+
-- your typstar configuration
|
|
148
|
+
add_undo_breakpoints = true,
|
|
149
|
+
})
|
|
150
|
+
end,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"L3MON4D3/LuaSnip",
|
|
154
|
+
version = "v2.*",
|
|
155
|
+
build = "make install_jsregexp",
|
|
156
|
+
config = function()
|
|
157
|
+
local luasnip = require("luasnip")
|
|
158
|
+
luasnip.config.setup({
|
|
159
|
+
enable_autosnippets = true,
|
|
160
|
+
cut_selection_keys = "<Tab>",
|
|
161
|
+
})
|
|
162
|
+
end,
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
"nvim-treesitter/nvim-treesitter",
|
|
166
|
+
build = ":TSUpdate",
|
|
167
|
+
branch = "main",
|
|
168
|
+
lazy = false,
|
|
169
|
+
config = function()
|
|
170
|
+
require('nvim-treesitter').install { "typst" }
|
|
171
|
+
end
|
|
172
|
+
},
|
|
173
|
+
```
|
|
174
|
+
</details>
|
|
175
|
+
|
|
176
|
+
### Snippets
|
|
177
|
+
0. The snippets are designed to work with Typst `0.14`. For older versions check out the legacy `typst-0.13` branch.
|
|
178
|
+
1. Install [LuaSnip](https://github.com/L3MON4D3/LuaSnip/), set `enable_autosnippets = true` and set a visual mode selection key (e.g. `cut_selection_keys = '<Tab>'`) in the configuration
|
|
179
|
+
2. Install [jsregexp](https://github.com/kmarius/jsregexp) as described [here](https://github.com/L3MON4D3/LuaSnip/blob/master/DOC.md#transformations) (You will see a warning on startup if jsregexp isn't installed properly)
|
|
180
|
+
3. Install [nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter) and run `:TSInstall typst`
|
|
181
|
+
4. Make sure you haven't remapped `<C-g>`. Otherwise set `add_undo_breakpoints = false` in the [config](#configuration)
|
|
182
|
+
5. Optional: Setup [ctheorems](https://typst.app/universe/package/ctheorems/) with names like [here](./lua/typstar/snippets/markup.lua)
|
|
183
|
+
|
|
184
|
+
### Excalidraw
|
|
185
|
+
1. Install [Obsidian](https://obsidian.md/) and create a vault in your typst note taking directory
|
|
186
|
+
2. Install the [obsidian-excalidraw-plugin](https://github.com/zsviczian/obsidian-excalidraw-plugin) and enable `Auto-export SVG` (in plugin settings at `Embedding Excalidraw into your Notes and Exporting > Export Settings > Auto-export Settings`)
|
|
187
|
+
3. Have the `xdg-open` command working or set a different command at `uriOpenCommand` in the [config](#configuration)
|
|
188
|
+
4. If you encounter issues with the file creation of drawings, try cloning the repo into `~/typstar` or setting the `typstarRoot` config accordingly; feel free to open an issue
|
|
189
|
+
|
|
190
|
+
### Rnote
|
|
191
|
+
1. Install [Rnote](https://github.com/flxzt/rnote?tab=readme-ov-file#installation); I recommend not using flatpak as that might cause issues with file permissions.
|
|
192
|
+
2. Make sure `rnote-cli` is available in your `PATH` or set a different command at `exportCommand` in the [config](#configuration)
|
|
193
|
+
3. Have the `xdg-open` command working with Rnote files or set a different command at `uriOpenCommand` in the [config](#configuration)
|
|
194
|
+
4. See comment 4 above at Excalidraw
|
|
195
|
+
|
|
196
|
+
### Anki
|
|
197
|
+
1. Install [Anki](https://apps.ankiweb.net/#download)
|
|
198
|
+
2. Install [Anki-Connect](https://ankiweb.net/shared/info/2055492159) and make sure `http://localhost` is added to `webCorsOriginList` in the Add-on config (should be added by default)
|
|
199
|
+
3. Install the typstar python package (I recommend using [uv](https://docs.astral.sh/uv/) via `uv tool install git+https://github.com/arne314/typstar`, you will need to have python build tools and clang installed) \[Note: this may take a while\]
|
|
200
|
+
4. Make sure the `typstar-anki` command is available in your `PATH` or modify the `typstarAnkiCmd` option in the [config](#configuration)
|
|
201
|
+
|
|
202
|
+
### In a Nix Flake (optional)
|
|
203
|
+
To try a minimal demo setup, run `nix run github:arne314/typstar#nvim -- test.typ` (200MB download).
|
|
204
|
+
The keybindings are defined [here](./lua/tests/basic_init.lua)
|
|
205
|
+
|
|
206
|
+
You can add typstar to your `nix-flake` like so
|
|
207
|
+
```nix
|
|
208
|
+
# `flake.nix`
|
|
209
|
+
inputs = {
|
|
210
|
+
# ... other inputs
|
|
211
|
+
typstar = {
|
|
212
|
+
url = "github:arne314/typstar";
|
|
213
|
+
flake = false;
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
Now you can use `typstar` in any package-set
|
|
218
|
+
```nix
|
|
219
|
+
with pkgs; [
|
|
220
|
+
# ... other packages
|
|
221
|
+
(pkgs.vimUtils.buildVimPlugin {
|
|
222
|
+
name = "typstar";
|
|
223
|
+
src = inputs.typstar;
|
|
224
|
+
buildInputs = with pkgs.vimPlugins; [
|
|
225
|
+
luasnip
|
|
226
|
+
nvim-treesitter-parsers.typst
|
|
227
|
+
];
|
|
228
|
+
})
|
|
229
|
+
]
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Configuration
|
|
233
|
+
Configuration options can be intuitively derived from the table [here](./lua/typstar/config.lua).
|
|
234
|
+
|
|
235
|
+
### Excalidraw/Rnote templates
|
|
236
|
+
The `templatePath` option expects a table that maps file patterns to template locations.
|
|
237
|
+
To for example have a specific template for lectures, you could configure it like this
|
|
238
|
+
```Lua
|
|
239
|
+
templatePath = {
|
|
240
|
+
{ 'lectures/.*%.excalidraw%.md$', '~/Templates/lecture_excalidraw.excalidraw.md' }, -- path contains "lectures"
|
|
241
|
+
{ '%.excalidraw%.md$', '~/Templates/default_excalidraw.excalidraw.md' }, -- fallback
|
|
242
|
+
},
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Custom snippets
|
|
246
|
+
The [config](#configuration) allows you to
|
|
247
|
+
- disable all snippets via `snippets.enable = false`
|
|
248
|
+
- only include specific modules from the snippets folder via e.g. `snippets.modules = { 'letters' }`
|
|
249
|
+
- exclude specific triggers via e.g. `snippets.exclude = { 'dx', 'ddx' }`
|
|
250
|
+
- disable different behaviors of snippets from the `visual` module
|
|
251
|
+
- visual selection via e.g. `snippets.visual_disable = { 'br' }`
|
|
252
|
+
- normal snippets (`abs` → `abs(1+1)`) via e.g. `snippets.visual_disable_normal = { 'abs' }`
|
|
253
|
+
- postfix snippets (`xabs` → `abs(x)`) via e.g. `snippets.visual_disable_postfix = { 'abs' }`
|
|
254
|
+
|
|
255
|
+
For further customization you can make use of the provided wrappers from within your [LuaSnip](https://github.com/L3MON4D3/LuaSnip/) config.
|
|
256
|
+
Let's say you prefer the short `=>` arrow over the long `==>` one and would like to change the `ip` trigger to `imp`.
|
|
257
|
+
Your `typstar` config could look like
|
|
258
|
+
```lua
|
|
259
|
+
require('typstar').setup({
|
|
260
|
+
snippets = {
|
|
261
|
+
exclude = { 'ip' },
|
|
262
|
+
},
|
|
263
|
+
})
|
|
264
|
+
```
|
|
265
|
+
while your LuaSnip `typst.lua` could look like this (`<` and `>` require escaping as `<>` [introduces a new node](https://github.com/L3MON4D3/LuaSnip/blob/master/DOC.md#fmt))
|
|
266
|
+
```lua
|
|
267
|
+
local tp = require('typstar.autosnippets')
|
|
268
|
+
local snip = tp.snip
|
|
269
|
+
local math = tp.in_math
|
|
270
|
+
local markup = tp.in_markup
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
-- add a new snippet (the old one is excluded via the config)
|
|
274
|
+
snip('imp', '=>> ', {}, math),
|
|
275
|
+
|
|
276
|
+
-- override existing triggers by setting a high priority
|
|
277
|
+
snip('ib', '<<= ', {}, math, 2000),
|
|
278
|
+
snip('iff', '<<=>> ', {}, math, 2000),
|
|
279
|
+
|
|
280
|
+
-- setup markup snippets accordingly
|
|
281
|
+
snip('IMP', '$=>>$ ', {}, markup, 2000),
|
|
282
|
+
snip('IFF', '$<<=>>$ ', {}, markup, 2000),
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## Contribution
|
|
287
|
+
Feel free to open an issue or a PR.
|
|
288
|
+
|
|
289
|
+
For development, a nix shell is provided, which you can enter via `nix develop`.
|
|
290
|
+
Running `nvim` from within the shell will launch a minimal installation of the plugin, sourced at startup, so no additional nix build is needed.
|
|
291
|
+
Tests can be executed using `just test` from within the shell or via `nix flake check`.
|
|
292
|
+
The code can be linted using `just lint`.
|
|
293
|
+
Run `just --list` for more details.
|
|
294
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
anki/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
anki/anki_api.py,sha256=Epev9zcKzuDmkKFBzqr6HprhRB3ysUrPj6mcnSl_oLg,5411
|
|
3
|
+
anki/config_parser.py,sha256=rdlcMYckpA8lkGa7ANnl6wSaBzCbUV9Y_rw-Z4rDku4,1277
|
|
4
|
+
anki/file_handler.py,sha256=Vk6FseOATTY_TOnlmgdn8MGGe2BLA17Udm2tblxPrQQ,1803
|
|
5
|
+
anki/flashcard.py,sha256=suW7oPISgh4_IRQHejctByTz0aaUw4H3XVIuRp_dEjA,2557
|
|
6
|
+
anki/main.py,sha256=rCbTi0PDoxCufxrJ1m6kXhLPL-CpqJPXqLjDVxIJx5Y,2474
|
|
7
|
+
anki/parser.py,sha256=eZ4GS4wRc9eUSph-15DJwVdL3f44A-cvKQs7zUYy3gU,6068
|
|
8
|
+
anki/typst_compiler.py,sha256=T3OdAyjQtfI_u641CZ77R8MAsEqHqvbvXe5hySuj_Dc,2765
|
|
9
|
+
typstar-1.5.0.dist-info/WHEEL,sha256=M4DeIjVCA49okfALADZoWX5JOGwnmHb-JOpQHtI-1c0,80
|
|
10
|
+
typstar-1.5.0.dist-info/entry_points.txt,sha256=vAWfnVW96tRj9ndiMhB-e1g0sMxO2mLsjE6VuOKEaN0,49
|
|
11
|
+
typstar-1.5.0.dist-info/METADATA,sha256=GWm4WSBd745UKnEpeK08pSuUZUqUMRdsKUg9vLEfKQc,13666
|
|
12
|
+
typstar-1.5.0.dist-info/RECORD,,
|