typstar 1.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
typstar-1.5.0/PKG-INFO ADDED
@@ -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
+ [![Weekly Integration](https://github.com/arne314/typstar/actions/workflows/weekly.yml/badge.svg)](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>` &#8594; `$<char>$ ` in markup (e.g. `:X` &#8594; `$X$ `, `:5` &#8594; `$5$ `)
37
+ - Greek letters: `;<latin>` &#8594; `<greek>` in math and `$<greek>$ ` in markup (e.g. `;a` &#8594; `alpha`/`$alpha$ `)
38
+ - Common indices (numbers and letters `i-n`): `<letter><index> ` &#8594; `<letter>_<index> ` in math and `$<letter>$ <index> ` &#8594; `$<letter>_<index>$ ` in markup (e.g `A314 ` &#8594; `A_314 `, `$alpha$ n ` &#8594; `$alpha_n$ `, `$F$ n,` &#8594; `$F_n$, `, `$F$ n.` &#8594; `$F_n$.`)
39
+ - Primes: `$<letter>$ ' ` &#8594; `$<letter>'$ ` and in combination with index and punctuation like above (e.g. `$phi$ ' ` &#8594; `$phi'$ `, `$phi$ 5'` &#8594; `$phi'_5$ `, `$f$ '5.` &#8594; `$f'_5$.`, `f'5,` &#8594; `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` &#8594; `#highlight[<cursor>]`, `IMP` &#8594; `$==>$ `)
47
+ - [ctheorems shorthands](./lua/typstar/snippets/markup.lua) (e.g. `tem` &#8594; empty theorem, `exa` &#8594; 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> ` &#8594; `<letter>_<0/1>, <letter>_<1/2>, ... ` (e.g. `a ot ` &#8594; `a_1, a_2, ... `, `a zt4 ` &#8594; `a_0, a_1, a_2, a_3, a_4 `, `alpha otk ` &#8594; `alpha_1, alpha_2, ..., alpha_k `, `oti ` &#8594; `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>` &#8594; `<operation>(<expression>)` (e.g. `(a^2+b^2)rt` &#8594; `sqrt(a^2+b^2)`, `lambdatd` &#8594; `tilde(lambda)`, `(1+1)sQ` &#8594; `[1+1]`, `(1+1)sq` &#8594; `[(1+1)]`)
55
+ - Simple functions: `fo<value> ` &#8594; `f(<value>) ` (e.g. `fox ` &#8594; `f(x) `, `ao5 ` &#8594; `a(5) `)
56
+ - Matrices: `<size>ma` and `<size>lma` (e.g. `23ma` &#8594; 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` &#8594; `abs(1+1)`) via e.g. `snippets.visual_disable_normal = { 'abs' }`
253
+ - postfix snippets (`xabs` &#8594; `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,280 @@
1
+ # Typstar
2
+ Neovim plugin for efficient (mathematical) note taking in Typst
3
+
4
+ See changes in [`CHANGELOG.md`](./CHANGELOG.md)
5
+
6
+ [![Weekly Integration](https://github.com/arne314/typstar/actions/workflows/weekly.yml/badge.svg)](https://github.com/arne314/typstar/actions/workflows/weekly.yml)
7
+
8
+ ## Features
9
+ - 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))
10
+ - Easy insertion of drawings using [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) or [Rnote](https://github.com/flxzt/rnote)
11
+ - Export of [Anki](https://apps.ankiweb.net/) flashcards \[No Neovim required\]
12
+
13
+ ## Usage
14
+
15
+ ### Snippets
16
+ Use `:TypstarToggleSnippets` to toggle all snippets at any time.
17
+ To efficiently navigate insert nodes and avoid overlapping ones,
18
+ use `:TypstarSmartJump` and `:TypstarSmartJumpBack`.
19
+ Available snippets can mostly be intuitively derived from [here](././lua/typstar/snippets), they include:
20
+
21
+ Universal snippets:
22
+ - Alphanumeric characters: `:<char>` &#8594; `$<char>$ ` in markup (e.g. `:X` &#8594; `$X$ `, `:5` &#8594; `$5$ `)
23
+ - Greek letters: `;<latin>` &#8594; `<greek>` in math and `$<greek>$ ` in markup (e.g. `;a` &#8594; `alpha`/`$alpha$ `)
24
+ - Common indices (numbers and letters `i-n`): `<letter><index> ` &#8594; `<letter>_<index> ` in math and `$<letter>$ <index> ` &#8594; `$<letter>_<index>$ ` in markup (e.g `A314 ` &#8594; `A_314 `, `$alpha$ n ` &#8594; `$alpha_n$ `, `$F$ n,` &#8594; `$F_n$, `, `$F$ n.` &#8594; `$F_n$.`)
25
+ - Primes: `$<letter>$ ' ` &#8594; `$<letter>'$ ` and in combination with index and punctuation like above (e.g. `$phi$ ' ` &#8594; `$phi'$ `, `$phi$ 5'` &#8594; `$phi'_5$ `, `$f$ '5.` &#8594; `$f'_5$.`, `f'5,` &#8594; `f'_5, `)
26
+
27
+ You can find a complete map of latin to greek letters including reasons for the less intuitive ones [here](./lua/typstar/snippets/letters.lua).
28
+ Note that some greek letters have multiple latin ones mapped to them.
29
+
30
+ Markup snippets:
31
+ - Begin inline math with `kk` and multiline math with `dm`
32
+ - [Markup shorthands](./lua/typstar/snippets/markup.lua) (e.g. `HIG` &#8594; `#highlight[<cursor>]`, `IMP` &#8594; `$==>$ `)
33
+ - [ctheorems shorthands](./lua/typstar/snippets/markup.lua) (e.g. `tem` &#8594; empty theorem, `exa` &#8594; empty example)
34
+ - [Flashcards](#anki): `fla` and `flA`
35
+ - All above snippets support visual mode via the [selection key](#installation)
36
+
37
+ Math snippets:
38
+ - [Many shorthands](./lua/typstar/snippets/math.lua) for mathematical expressions
39
+ - Series of numbered letters: `<letter> <z/o>t<optional last index> ` &#8594; `<letter>_<0/1>, <letter>_<1/2>, ... ` (e.g. `a ot ` &#8594; `a_1, a_2, ... `, `a zt4 ` &#8594; `a_0, a_1, a_2, a_3, a_4 `, `alpha otk ` &#8594; `alpha_1, alpha_2, ..., alpha_k `, `oti ` &#8594; `1, 2, ..., i `)
40
+ - 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>` &#8594; `<operation>(<expression>)` (e.g. `(a^2+b^2)rt` &#8594; `sqrt(a^2+b^2)`, `lambdatd` &#8594; `tilde(lambda)`, `(1+1)sQ` &#8594; `[1+1]`, `(1+1)sq` &#8594; `[(1+1)]`)
41
+ - Simple functions: `fo<value> ` &#8594; `f(<value>) ` (e.g. `fox ` &#8594; `f(x) `, `ao5 ` &#8594; `a(5) `)
42
+ - Matrices: `<size>ma` and `<size>lma` (e.g. `23ma` &#8594; 2x3 matrix)
43
+
44
+ Note that you can [customize](#custom-snippets) (enable, disable and modify) every snippet.
45
+
46
+ ### Excalidraw/Rnote
47
+ - Use `:TypstarInsertExcalidraw`/`:TypstarInsertRnote` to
48
+ create a new drawing using the [configured](#configuration) template,
49
+ insert a figure displaying it and open it in Obsidian/Rnote.
50
+ - To open an inserted drawing in Obsidian/Rnote,
51
+ simply run `:TypstarOpenDrawing` (or `:TypstarOpenExcalidraw`/`:TypstarOpenRnote` if you are using the same file extension for both)
52
+ while your cursor is on a line referencing the drawing.
53
+
54
+ ### Anki
55
+ Use the `flA` snippet to create a new flashcard
56
+ ```typst
57
+ #flashcard(0, "My first flashcard")[
58
+ Typst is awesome $a^2+b^2=c^2$
59
+ ]
60
+ ```
61
+ or the `fla` snippet to add a more complex front
62
+ ```typst
63
+ #flashcard(0)[I love Typst $pi$][
64
+ This is the back of my second flashcard
65
+ ]
66
+ ```
67
+
68
+ To render the flashcard in your document as well add some code like this
69
+ ```typst
70
+ #let flashcard(id, front, back) = {
71
+ strong(front)
72
+ [\ ]
73
+ back
74
+ }
75
+ ```
76
+
77
+ - 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)
78
+ - Add a file named `.anki` containing a deck name to define a default deck on a directory base
79
+ - 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).
80
+ - 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
81
+
82
+ #### Neovim
83
+ - Use `:TypstarAnkiScan` to scan the current nvim working directory and compile all flashcards in its context, unchanged files will be ignored
84
+ - 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)
85
+ - Use `:TypstarAnkiForceCurrent` to force compilation of all flashcards in the file currently edited
86
+ - Use `:TypstarAnkiReimport` to also add flashcards that have already been assigned an id but are not currently
87
+ present in Anki
88
+ - Use `:TypstarAnkiForceReimport` and `:TypstarAnkiForceCurrentReimport` to combine features accordingly
89
+
90
+ #### Standalone
91
+ - Run `typstar-anki --help` to show the available options
92
+
93
+
94
+ ## Installation
95
+ Install the plugin in Neovim and run the plugin setup.
96
+ You can install and run a demo installation using [Nix](#in-a-nix-flake-optional)).
97
+ ```lua
98
+ require('typstar').setup({ -- depending on your neovim plugin system
99
+ -- your typstar config goes here
100
+ })
101
+ ```
102
+
103
+ <details>
104
+ <summary>Example lazy.nvim config</summary>
105
+
106
+ ```lua
107
+ {
108
+ "arne314/typstar",
109
+ dependencies = {
110
+ "L3MON4D3/LuaSnip",
111
+ },
112
+ ft = { "typst" },
113
+ keys = {
114
+ {
115
+ "<M-t>",
116
+ "<Cmd>TypstarToggleSnippets<CR>",
117
+ mode = { "n", "i" },
118
+ },
119
+ {
120
+ "<M-j>",
121
+ "<Cmd>TypstarSmartJump<CR>",
122
+ mode = { "s", "i" },
123
+ },
124
+ {
125
+ "<M-k>",
126
+ "<Cmd>TypstarSmartJumpBack<CR>",
127
+ mode = { "s", "i" },
128
+ },
129
+ },
130
+ config = function()
131
+ local typstar = require("typstar")
132
+ typstar.setup({
133
+ -- your typstar configuration
134
+ add_undo_breakpoints = true,
135
+ })
136
+ end,
137
+ },
138
+ {
139
+ "L3MON4D3/LuaSnip",
140
+ version = "v2.*",
141
+ build = "make install_jsregexp",
142
+ config = function()
143
+ local luasnip = require("luasnip")
144
+ luasnip.config.setup({
145
+ enable_autosnippets = true,
146
+ cut_selection_keys = "<Tab>",
147
+ })
148
+ end,
149
+ },
150
+ {
151
+ "nvim-treesitter/nvim-treesitter",
152
+ build = ":TSUpdate",
153
+ branch = "main",
154
+ lazy = false,
155
+ config = function()
156
+ require('nvim-treesitter').install { "typst" }
157
+ end
158
+ },
159
+ ```
160
+ </details>
161
+
162
+ ### Snippets
163
+ 0. The snippets are designed to work with Typst `0.14`. For older versions check out the legacy `typst-0.13` branch.
164
+ 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
165
+ 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)
166
+ 3. Install [nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter) and run `:TSInstall typst`
167
+ 4. Make sure you haven't remapped `<C-g>`. Otherwise set `add_undo_breakpoints = false` in the [config](#configuration)
168
+ 5. Optional: Setup [ctheorems](https://typst.app/universe/package/ctheorems/) with names like [here](./lua/typstar/snippets/markup.lua)
169
+
170
+ ### Excalidraw
171
+ 1. Install [Obsidian](https://obsidian.md/) and create a vault in your typst note taking directory
172
+ 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`)
173
+ 3. Have the `xdg-open` command working or set a different command at `uriOpenCommand` in the [config](#configuration)
174
+ 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
175
+
176
+ ### Rnote
177
+ 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.
178
+ 2. Make sure `rnote-cli` is available in your `PATH` or set a different command at `exportCommand` in the [config](#configuration)
179
+ 3. Have the `xdg-open` command working with Rnote files or set a different command at `uriOpenCommand` in the [config](#configuration)
180
+ 4. See comment 4 above at Excalidraw
181
+
182
+ ### Anki
183
+ 1. Install [Anki](https://apps.ankiweb.net/#download)
184
+ 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)
185
+ 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\]
186
+ 4. Make sure the `typstar-anki` command is available in your `PATH` or modify the `typstarAnkiCmd` option in the [config](#configuration)
187
+
188
+ ### In a Nix Flake (optional)
189
+ To try a minimal demo setup, run `nix run github:arne314/typstar#nvim -- test.typ` (200MB download).
190
+ The keybindings are defined [here](./lua/tests/basic_init.lua)
191
+
192
+ You can add typstar to your `nix-flake` like so
193
+ ```nix
194
+ # `flake.nix`
195
+ inputs = {
196
+ # ... other inputs
197
+ typstar = {
198
+ url = "github:arne314/typstar";
199
+ flake = false;
200
+ };
201
+ }
202
+ ```
203
+ Now you can use `typstar` in any package-set
204
+ ```nix
205
+ with pkgs; [
206
+ # ... other packages
207
+ (pkgs.vimUtils.buildVimPlugin {
208
+ name = "typstar";
209
+ src = inputs.typstar;
210
+ buildInputs = with pkgs.vimPlugins; [
211
+ luasnip
212
+ nvim-treesitter-parsers.typst
213
+ ];
214
+ })
215
+ ]
216
+ ```
217
+
218
+ ## Configuration
219
+ Configuration options can be intuitively derived from the table [here](./lua/typstar/config.lua).
220
+
221
+ ### Excalidraw/Rnote templates
222
+ The `templatePath` option expects a table that maps file patterns to template locations.
223
+ To for example have a specific template for lectures, you could configure it like this
224
+ ```Lua
225
+ templatePath = {
226
+ { 'lectures/.*%.excalidraw%.md$', '~/Templates/lecture_excalidraw.excalidraw.md' }, -- path contains "lectures"
227
+ { '%.excalidraw%.md$', '~/Templates/default_excalidraw.excalidraw.md' }, -- fallback
228
+ },
229
+ ```
230
+
231
+ ### Custom snippets
232
+ The [config](#configuration) allows you to
233
+ - disable all snippets via `snippets.enable = false`
234
+ - only include specific modules from the snippets folder via e.g. `snippets.modules = { 'letters' }`
235
+ - exclude specific triggers via e.g. `snippets.exclude = { 'dx', 'ddx' }`
236
+ - disable different behaviors of snippets from the `visual` module
237
+ - visual selection via e.g. `snippets.visual_disable = { 'br' }`
238
+ - normal snippets (`abs` &#8594; `abs(1+1)`) via e.g. `snippets.visual_disable_normal = { 'abs' }`
239
+ - postfix snippets (`xabs` &#8594; `abs(x)`) via e.g. `snippets.visual_disable_postfix = { 'abs' }`
240
+
241
+ For further customization you can make use of the provided wrappers from within your [LuaSnip](https://github.com/L3MON4D3/LuaSnip/) config.
242
+ Let's say you prefer the short `=>` arrow over the long `==>` one and would like to change the `ip` trigger to `imp`.
243
+ Your `typstar` config could look like
244
+ ```lua
245
+ require('typstar').setup({
246
+ snippets = {
247
+ exclude = { 'ip' },
248
+ },
249
+ })
250
+ ```
251
+ 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))
252
+ ```lua
253
+ local tp = require('typstar.autosnippets')
254
+ local snip = tp.snip
255
+ local math = tp.in_math
256
+ local markup = tp.in_markup
257
+
258
+ return {
259
+ -- add a new snippet (the old one is excluded via the config)
260
+ snip('imp', '=>> ', {}, math),
261
+
262
+ -- override existing triggers by setting a high priority
263
+ snip('ib', '<<= ', {}, math, 2000),
264
+ snip('iff', '<<=>> ', {}, math, 2000),
265
+
266
+ -- setup markup snippets accordingly
267
+ snip('IMP', '$=>>$ ', {}, markup, 2000),
268
+ snip('IFF', '$<<=>>$ ', {}, markup, 2000),
269
+ }
270
+ ```
271
+
272
+ ## Contribution
273
+ Feel free to open an issue or a PR.
274
+
275
+ For development, a nix shell is provided, which you can enter via `nix develop`.
276
+ Running `nvim` from within the shell will launch a minimal installation of the plugin, sourced at startup, so no additional nix build is needed.
277
+ Tests can be executed using `just test` from within the shell or via `nix flake check`.
278
+ The code can be linted using `just lint`.
279
+ Run `just --list` for more details.
280
+
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "typstar"
3
+ version = "1.5.0"
4
+ description = "Neovim plugin for efficient note taking in Typst"
5
+ authors = [
6
+ { name = "arne314" }
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.11.10"
10
+ dependencies = [
11
+ "aiohttp>=3.13.3",
12
+ "appdirs>=1.4.4",
13
+ "tree-sitter==0.25.2",
14
+ "tree-sitter-typst",
15
+ "typer>=0.24.1",
16
+ "typing-extensions>=4.15.0",
17
+ ]
18
+
19
+ [project.scripts]
20
+ typstar-anki = "anki.main:main"
21
+
22
+ [tool.ruff]
23
+ lint.extend-select = ["I"]
24
+ line-length = 100
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "ruff>=0.8.5",
29
+ ]
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.11.2,<0.12"]
33
+ build-backend = "uv_build"
34
+
35
+ [tool.uv.build-backend]
36
+ module-name = "anki"
37
+
38
+ [tool.uv.workspace]
39
+ members = [
40
+ "vendor/tree_sitter_typst",
41
+ ]
42
+
43
+ [tool.uv.sources]
44
+ tree-sitter-typst = { workspace = true }
45
+
File without changes
@@ -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
@@ -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
@@ -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)
@@ -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
@@ -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()
@@ -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()
@@ -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