├── .github
└── workflows
│ └── release.yml
├── .gitignore
├── LICENSE
├── README.md
├── __init__.py
├── anki_roam_import
├── __init__.py
├── anki.py
├── anki_format.py
├── importer.py
├── model.py
├── parser.py
├── plugin.py
└── roam.py
├── build.py
├── config.json
├── config.md
├── manifest.json
├── setup.py
└── tests
├── test_anki_format.py
├── test_cloze_enumerator.py
├── test_importer.py
├── test_parser.py
├── test_roam.py
├── test_roam_parser.py
├── test_source_builder.py
└── util.py
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on: push
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - name: Check out code
10 | uses: actions/checkout@v2
11 | - name: Set up Python
12 | uses: actions/setup-python@v1
13 | with:
14 | python-version: 3.8
15 | - name: Install dependencies
16 | run: |
17 | python -m pip install --upgrade pip
18 | pip install .
19 | - name: Test with pytest
20 | run: |
21 | pip install pytest
22 | python -m pytest
23 | - name: Build Anki add-on
24 | run: python build.py
25 | - name: Create Release
26 | id: create_release
27 | uses: actions/create-release@v1
28 | env:
29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30 | with:
31 | tag_name: v${{ github.run_number }}
32 | release_name: v${{ github.run_number }}
33 | - name: Upload Anki add-on file
34 | id: upload-release-asset
35 | uses: actions/upload-release-asset@v1
36 | env:
37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38 | with:
39 | upload_url: ${{ steps.create_release.outputs.upload_url }}
40 | asset_path: anki_roam_import.ankiaddon
41 | asset_name: anki_roam_import.ankiaddon
42 | asset_content_type: application/zip
43 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | __pycache__
3 | *.ankiaddon
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Gabriel McManus
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Anki Roam Import
2 |
3 | Anki add-on to import cloze notes from [Roam](https://roamresearch.com/).
4 |
5 | Mark up your cloze deletions in Roam with curly brackets `{like this}`, export
6 | your Roam database to JSON, and then import it into Anki. The addon will create
7 | a cloze note for each Roam block (bullet point) that is marked up with curly
8 | brackets.
9 |
10 | ## Why?
11 |
12 | Roam provides a low-friction way to record, refine, and link your ideas.
13 | Some of the notes you make in Roam are worth remembering using Anki,
14 | and it is nicer to edit them in context in Roam rather than writing them one at a time in Anki.
15 |
16 | ## Installation
17 |
18 | Ensure you have a recent version of [Anki](https://apps.ankiweb.net/) installed
19 | (2.1.16 or newer).
20 |
21 | 1. Copy this code to your clipboard:
22 | ```
23 | 1172449017
24 | ```
25 | 2. Open the Anki desktop application.
26 | 3. In the Anki main window menu, choose "Tools" then "Add-ons".
27 | 4. Click the "Get Add-ons..." button.
28 | 5. Paste the code you copied above into the text box, then click the OK button.
29 |
30 | ## Creating cloze notes in Roam
31 |
32 | You should be familiar with how Anki supports
33 | [cloze deletion](https://apps.ankiweb.net/docs/manual.html#cloze-deletion).
34 |
35 | To indicate a Roam block (bullet point) should be converted into an Anki cloze
36 | note, put single curly brackets around each cloze deletion `{like this}`.
37 |
38 | *Tip*: if you select some text in Roam and then press the `{` key, it will
39 | surround that text in curly brackets. This can be a fast way to mark up cloze
40 | deletions.
41 |
42 | The add-on will automatically number the clozes when it is imported into Anki.
43 | If you want to create Anki cards with multiple cloze deletions, then you can
44 | number the cloze deletions manually. Indicate the cloze number
45 | `{c1|like this}`. You don't need to indicate cloze numbers in all of the cloze
46 | deletions - the unnumbered cloze deletions will be automatically numbered
47 | differently.
48 |
49 | You can indicate cloze hints `{like this|hint}`. You can have both a cloze
50 | cloze number and a hint `{c1|like this|hint}`.
51 |
52 | Some examples of how the Roam blocks are converted to Anki notes:
53 |
54 | Roam block | Anki note
55 | ---------- | -------------
56 | `{Automatic} {cloze} {numbering}.` | `{{c1::Automatic}} {{c2::cloze}} {{c3::numbering}}.`
57 | `{c2\|Manual} {c1\|cloze} {c2\|numbering}.` | `{{c2::Automatic}} {{c1::cloze}} {{c2::numbering}}.`
58 | `{c2\|Mix} of {automatic} and {c2\|manual} {c1\|numbering}.` | `{{c2::Mix}} of {{c3::automatic}} and {{c2::manual}} {{c1::numbering}}.`
59 | `{Cloze\|with hint} and {c3\|another\|with another hint}.` | `{{c1::Cloze::with hint}} and {{c3::another::with another hint}}.`
60 |
61 | The Roam cloze deletion syntax has some differences from the Anki syntax:
62 |
63 | * It uses `{single curly brackets}` because `{{double curly brackets}}` have
64 | another meaning in Roam (e.g. `{{[TODO]}}` is used to create a to-do item
65 | checkbox).
66 | * It uses a vertical pipe `|` because double colons `::` have another meaning in
67 | Roam (to create
68 | [attributes](https://roamresearch.com/#/v8/help/page/LJOc7nRiO)).
69 | * Cloze numbers are optional to make the notes easier to write.
70 |
71 | ## Exporting and importing
72 |
73 | To export from Roam:
74 |
75 | 1. Click on the ellipsis button (...) in the top right menu, then choose
76 | "Export All". (You can also choose "Export" to export just the current page,
77 | but it's easier to export all each time. Don't worry, the add-on won't import
78 | [duplicate notes](#duplicate-notes).)
79 | 2. Choose JSON as the export format and click the Export All button.
80 | You will download a ZIP file.
81 |
82 | To import into Anki:
83 |
84 | 1. In the Anki main window menu, choose "Tools" then "Import Roam notes...".
85 | 2. Choose the ZIP file you downloaded from Roam.
86 | 3. Any new notes will be imported and a dialog will show how many were imported
87 | and how many were ignored.
88 |
89 | You can then check which notes were created by looking in the card browser.
90 |
91 | ### Duplicate notes
92 |
93 | Notes that already exist in the deck will not be imported.
94 |
95 | In addition, the add-on records every note it imports to a file,
96 | and it won't import the same note twice.
97 | This means that after you import a note into Anki,
98 | you can change that Anki note as you like (e.g. edit it or delete it),
99 | and it the old note won't be imported again.
100 |
101 |
102 | ## Configuration
103 |
104 | You can configure the add-on by choosing "Add-ons" from the "Tools" menu,
105 | selecting the add-on and then clicking the "Config" button. The configuration
106 | fields are:
107 |
108 | * `model_name` is the name of the model to use for imported notes. Defaults to
109 | "Cloze".
110 | * `content_field` is the name of the field in which to put the content of the
111 | note. Defaults to "Text".
112 | * `source_field` is the name of the field in which to put the source of the note.
113 | Defaults to null, which means the source is not recorded.
114 | * `deck_name` is the name of the deck in which to put the imported cards.
115 | Defaults to null, which means use the default deck.
116 |
117 | ## Indicating the source of the note
118 |
119 | If you configure the source_field
120 | then the add-on will record the source of each note in that field.
121 |
122 | If you put a Roam block (bullet point) that begins with 'Source:' (case insensitive)
123 | near the block of the Roam cloze note, then it will be recorded as the source of
124 | the note. The add-on looks for sources in these locations:
125 | * Immediate children of the note block.
126 | * Parent of the note block, its parents, and so on.
127 |
128 |
129 | ## Areas for improvement
130 |
131 | * Currently only a single Roam block is used to create a single Anki note.
132 | * Better conversion of Roam Markdown into Anki HTML e.g. links
133 |
--------------------------------------------------------------------------------
/__init__.py:
--------------------------------------------------------------------------------
1 | from .anki_roam_import import plugin
2 |
3 | plugin.main()
4 |
--------------------------------------------------------------------------------
/anki_roam_import/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gmcmanus/anki-roam-import/4491486ea631ba756490d6a22829375202fa4138/anki_roam_import/__init__.py
--------------------------------------------------------------------------------
/anki_roam_import/anki.py:
--------------------------------------------------------------------------------
1 | import os
2 | from copy import deepcopy
3 | from dataclasses import dataclass
4 | from typing import Iterable, Optional
5 |
6 | from .model import JsonData, AnkiNote
7 |
8 |
9 | def is_anki_package_installed() -> bool:
10 | try:
11 | import anki
12 | except ModuleNotFoundError:
13 | return False
14 | else:
15 | return True
16 |
17 |
18 | if is_anki_package_installed():
19 | from anki.collection import _Collection
20 | from anki.models import NoteType
21 | from anki.notes import Note
22 | from anki.utils import splitFields
23 | from aqt.main import AnkiQt
24 | else:
25 | # allow running tests without anki package installed
26 | from typing import Any, Dict
27 |
28 | _Collection = Any
29 | NoteType = Dict[str, Any]
30 | AnkiQt = Any
31 |
32 | class Note:
33 | def __init__(self, collection, model):
34 | self.fields = []
35 |
36 | # noinspection PyPep8Naming
37 | def splitFields(fields):
38 | return []
39 |
40 |
41 | @dataclass
42 | class AnkiModelNotes:
43 | collection: _Collection
44 | model: NoteType
45 | content_field_index: int
46 | source_field_index: Optional[int]
47 |
48 | def add_note(self, anki_note: AnkiNote) -> None:
49 | note = self._note(anki_note)
50 | self.collection.addNote(note)
51 |
52 | def _note(self, anki_note: AnkiNote) -> Note:
53 | note = Note(self.collection, self.model)
54 | note.fields[self.content_field_index] = anki_note.content
55 | if self.source_field_index is not None:
56 | note.fields[self.source_field_index] = anki_note.source
57 | return note
58 |
59 | def get_notes(self) -> Iterable[str]:
60 | note_fields = self.collection.db.list(
61 | 'select flds from notes where mid = ?', self.model['id'])
62 | for fields in note_fields:
63 | yield splitFields(fields)[self.content_field_index]
64 |
65 |
66 | @dataclass
67 | class AnkiAddonData:
68 | anki_qt: AnkiQt
69 |
70 | def read_config(self) -> JsonData:
71 | return self.anki_qt.addonManager.getConfig(__name__)
72 |
73 | def user_files_path(self) -> str:
74 | module_name = __name__.split('.')[0]
75 | addons_directory = self.anki_qt.addonManager.addonsFolder(module_name)
76 | user_files_path = os.path.join(addons_directory, 'user_files')
77 | os.makedirs(user_files_path, exist_ok=True)
78 | return user_files_path
79 |
80 |
81 | @dataclass
82 | class AnkiCollection:
83 | collection: _Collection
84 |
85 | def get_model_notes(
86 | self,
87 | model_name: str,
88 | content_field: str,
89 | source_field: Optional[str],
90 | deck_name: Optional[str],
91 | ) -> AnkiModelNotes:
92 | model = self._get_model(model_name, deck_name)
93 |
94 | field_names = self.collection.models.fieldNames(model)
95 | content_field_index = field_names.index(content_field)
96 |
97 | if source_field is not None:
98 | source_field_index = field_names.index(source_field)
99 | else:
100 | source_field_index = None
101 |
102 | return AnkiModelNotes(
103 | self.collection, model, content_field_index, source_field_index)
104 |
105 | def _get_model(self, model_name: str, deck_name: Optional[str]) -> NoteType:
106 | model = deepcopy(self.collection.models.byName(model_name))
107 | self._set_deck_for_new_cards(model, deck_name)
108 | return model
109 |
110 | def _set_deck_for_new_cards(
111 | self, model: NoteType, deck_name: Optional[str],
112 | ) -> None:
113 | if deck_name:
114 | deck_id = self.collection.decks.id(deck_name, create=True)
115 | model['did'] = deck_id
116 |
--------------------------------------------------------------------------------
/anki_roam_import/anki_format.py:
--------------------------------------------------------------------------------
1 | import html
2 | import re
3 | from dataclasses import dataclass, replace
4 | from typing import Any, Callable, Iterable, List, Match, Optional, TypeVar
5 |
6 | from .model import (
7 | AnkiNote, Cloze, ClozePart, CodeBlock, CodeInline, Math, RoamBlock,
8 | RoamColonCommand, RoamCurlyCommand, RoamPart,
9 | )
10 |
11 |
12 | OptionalFormatter = Callable[[Any], Optional[str]]
13 | T = TypeVar('T')
14 | Formatter = Callable[[T], str]
15 |
16 |
17 | @dataclass
18 | class AnkiNoteMaker:
19 | cloze_enumerator: 'ClozeEnumerator'
20 | roam_parts_formatter: Formatter[Iterable[RoamPart]]
21 | html_formatter: Formatter[str]
22 |
23 | def __call__(self, roam_block: RoamBlock) -> AnkiNote:
24 | numbered_parts = self.cloze_enumerator(roam_block.parts)
25 | anki_content = self.roam_parts_formatter(numbered_parts)
26 | source_html = self.html_formatter(roam_block.source)
27 | return AnkiNote(anki_content, source_html)
28 |
29 |
30 | class ClozeEnumerator:
31 | def __call__(self, parts: List[RoamPart]) -> Iterable[RoamPart]:
32 | used_numbers = {
33 | part.number
34 | for part in parts
35 | if isinstance(part, Cloze) and has_valid_number(part)
36 | }
37 | next_candidate_number = 1
38 |
39 | for part in parts:
40 | if isinstance(part, Cloze) and not has_valid_number(part):
41 | while next_candidate_number in used_numbers:
42 | next_candidate_number += 1
43 | part = replace(part, number=next_candidate_number)
44 | used_numbers.add(next_candidate_number)
45 |
46 | yield part
47 |
48 |
49 | def has_valid_number(cloze: Cloze) -> bool:
50 | return cloze.number and cloze.number > 0
51 |
52 |
53 | def combine_formatters(*formatters: OptionalFormatter) -> Formatter:
54 | def combined_formatter(value: Any) -> str:
55 | for formatter in formatters:
56 | string = formatter(value)
57 | if string is not None:
58 | return string
59 | raise ValueError
60 |
61 | return combined_formatter
62 |
63 |
64 | def roam_parts_formatter(
65 | roam_part_formatter: Formatter[RoamPart],
66 | ) -> Formatter[Iterable[RoamPart]]:
67 | def format_roam_parts(parts: Iterable[RoamPart]) -> str:
68 | return ''.join(map(roam_part_formatter, parts))
69 |
70 | return format_roam_parts
71 |
72 |
73 | def cloze_formatter(
74 | cloze_part_formatter: Formatter[ClozePart], html_formatter: Formatter[str],
75 | ) -> OptionalFormatter:
76 | def format_cloze(cloze: Any) -> Optional[str]:
77 | if not isinstance(cloze, Cloze):
78 | return None
79 |
80 | if not has_valid_number(cloze):
81 | raise ValueError
82 |
83 | if cloze.hint is not None:
84 | hint_html = html_formatter(cloze.hint)
85 | hint = f'::{hint_html}'
86 | else:
87 | hint = ''
88 |
89 | content = ''.join(map(cloze_part_formatter, cloze.parts))
90 |
91 | return '{{' + f'c{cloze.number}::{content}{hint}' + '}}'
92 |
93 | return format_cloze
94 |
95 |
96 | def format_text_as_html(text: str) -> str:
97 | escaped_html = html.escape(text)
98 | escaped_html = MULTIPLE_SPACES.sub(replace_spaces_with_nbsp, escaped_html)
99 | return escaped_html.replace('\n', '
')
100 |
101 |
102 | MULTIPLE_SPACES = re.compile(' {2,}')
103 |
104 |
105 | def replace_spaces_with_nbsp(match: Match):
106 | return ' ' * len(match.group())
107 |
108 |
109 | def math_formatter(html_formatter: Formatter[str]) -> OptionalFormatter:
110 | def format_math(math: Any) -> Optional[str]:
111 | if not isinstance(math, Math):
112 | return None
113 | math_html = html_formatter(math.content)
114 | return rf'\({math_html}\)'
115 |
116 | return format_math
117 |
118 |
119 | def code_block_formatter(code_formatter: Formatter[str]) -> OptionalFormatter:
120 | def format_code_block(code_block: Any) -> Optional[str]:
121 | if not isinstance(code_block, CodeBlock):
122 | return None
123 | inline_code = code_formatter(code_block.content)
124 | return f'
{inline_code}' 125 | 126 | return format_code_block 127 | 128 | 129 | def code_inline_formatter(code_formatter: Formatter[str]) -> OptionalFormatter: 130 | def format_code_inline(code_inline: Any) -> Optional[str]: 131 | if not isinstance(code_inline, CodeInline): 132 | return None 133 | return code_formatter(code_inline.content) 134 | 135 | return format_code_inline 136 | 137 | 138 | def format_code(code: str) -> str: 139 | escaped_code = html.escape(code) 140 | return f'
{escaped_code}
'
141 |
142 |
143 | def roam_curly_command_formatter(
144 | html_formatter: Formatter[str],
145 | ) -> OptionalFormatter:
146 | def format_roam_curly_command(curly_command: Any) -> Optional[str]:
147 | if not isinstance(curly_command, RoamCurlyCommand):
148 | return None
149 | command_html = html_formatter(curly_command.content)
150 | return '{{' + command_html + '}}'
151 |
152 | return format_roam_curly_command
153 |
154 |
155 | def roam_colon_command_formatter(
156 | html_formatter: Formatter[str],
157 | ) -> OptionalFormatter:
158 | def format_roam_colon_command(colon_command: Any) -> Optional[str]:
159 | if not isinstance(colon_command, RoamColonCommand):
160 | return None
161 | command_html = html_formatter(colon_command.command)
162 | content_html = html_formatter(colon_command.content)
163 | return f':{command_html}{content_html}'
164 |
165 | return format_roam_colon_command
166 |
167 |
168 | def string_formatter(html_formatter: Formatter[str]) -> OptionalFormatter:
169 | def format_string(string: Any) -> Optional[str]:
170 | if not isinstance(string, str):
171 | return None
172 | return html_formatter(string)
173 | return format_string
174 |
175 |
176 | def make_anki_note_maker():
177 | format_string = string_formatter(format_text_as_html)
178 | format_math = math_formatter(format_text_as_html)
179 | format_code_inline = code_inline_formatter(format_code)
180 | format_code_block = code_block_formatter(format_code)
181 |
182 | cloze_part_formatter = combine_formatters(
183 | format_string,
184 | format_math,
185 | format_code_block,
186 | format_code_inline,
187 | )
188 |
189 | roam_part_formatter = combine_formatters(
190 | format_string,
191 | cloze_formatter(cloze_part_formatter, format_text_as_html),
192 | format_math,
193 | format_code_block,
194 | format_code_inline,
195 | roam_curly_command_formatter(format_text_as_html),
196 | roam_colon_command_formatter(format_text_as_html),
197 | )
198 |
199 | return AnkiNoteMaker(
200 | ClozeEnumerator(),
201 | roam_parts_formatter(roam_part_formatter),
202 | format_text_as_html,
203 | )
204 |
205 |
206 | make_anki_note = make_anki_note_maker()
207 |
--------------------------------------------------------------------------------
/anki_roam_import/importer.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os.path
3 | import re
4 | from dataclasses import dataclass
5 | from typing import Iterable, List
6 |
7 | from .anki import (
8 | AnkiAddonData, AnkiCollection, AnkiModelNotes, is_anki_package_installed,
9 | )
10 | from .anki_format import make_anki_note
11 | from .model import AnkiNote
12 | from .roam import extract_roam_blocks, load_roam_pages
13 |
14 | if is_anki_package_installed():
15 | from anki.utils import stripHTMLMedia
16 | else:
17 | # allow running tests without anki package installed
18 | # noinspection PyPep8Naming
19 | def stripHTMLMedia(content): return content
20 |
21 |
22 | @dataclass
23 | class AnkiNoteImporter:
24 | addon_data: AnkiAddonData
25 | collection: AnkiCollection
26 |
27 | def import_from_path(self, path: str) -> str:
28 | roam_pages = load_roam_pages(path)
29 | roam_notes = extract_roam_blocks(roam_pages)
30 | notes_to_add = map(make_anki_note, roam_notes)
31 |
32 | num_notes_added = 0
33 | num_notes_ignored = 0
34 |
35 | added_notes_file = AddedNotesFile(added_notes_path(self.addon_data))
36 | added_notes = added_notes_file.read()
37 |
38 | normalized_notes = NormalizedNotes()
39 | normalized_notes.update(added_notes_file.read())
40 |
41 | config = self.addon_data.read_config()
42 | model_notes = self.collection.get_model_notes(
43 | config['model_name'],
44 | config['content_field'],
45 | config['source_field'],
46 | config['deck_name'],
47 | )
48 | note_adder = AnkiNoteAdder(model_notes, added_notes, normalized_notes)
49 |
50 | for note in notes_to_add:
51 | if note_adder.try_add(note):
52 | num_notes_added += 1
53 | else:
54 | num_notes_ignored += 1
55 |
56 | note_adder.write(added_notes_file)
57 |
58 | def info():
59 | if not num_notes_added and not num_notes_ignored:
60 | yield 'No notes found'
61 | return
62 |
63 | if num_notes_added:
64 | yield f'{num_notes_added} new notes imported'
65 |
66 | if num_notes_ignored:
67 | yield f'{num_notes_ignored} notes were imported before and were not imported again'
68 |
69 | return ', '.join(info()) + '.'
70 |
71 |
72 | class NormalizedNotes:
73 | def __init__(self):
74 | self.normalized_contents = set()
75 |
76 | def __contains__(self, content):
77 | return normalized_content(content) in self.normalized_contents
78 |
79 | def add(self, content):
80 | self.normalized_contents.add(normalized_content(content))
81 |
82 | def update(self, contents):
83 | self.normalized_contents.update(map(normalized_content, contents))
84 |
85 |
86 | def normalized_content(content: str) -> str:
87 | content_without_html = stripHTMLMedia(content)
88 | stripped_content = CHARACTERS_TO_STRIP.sub('', content_without_html)
89 | return ' '.join(stripped_content.split())
90 |
91 |
92 | CHARACTERS_TO_STRIP = re.compile(r'[!"\'\(\),\-\.:;\?\[\]_`\{\}]')
93 |
94 |
95 | class AddedNotesFile:
96 | def __init__(self, path):
97 | self.path = path
98 |
99 | def read(self) -> List[str]:
100 | if not os.path.isfile(self.path):
101 | return []
102 |
103 | with open(self.path, encoding='utf-8') as file:
104 | return json.load(file)
105 |
106 | def write(self, notes: List[str]):
107 | with open(self.path, encoding='utf-8', mode='w') as file:
108 | json.dump(notes, file)
109 |
110 |
111 | def added_notes_path(addon_data: AnkiAddonData) -> str:
112 | user_files_path = addon_data.user_files_path()
113 | return os.path.join(user_files_path, 'added_notes.json')
114 |
115 |
116 | class AnkiNoteAdder:
117 | def __init__(
118 | self,
119 | model_notes: AnkiModelNotes,
120 | added_notes: Iterable[str],
121 | normalized_notes: NormalizedNotes,
122 | ):
123 | self.model_notes = model_notes
124 | self.added_contents = list(added_notes)
125 | self.normalized_notes = normalized_notes
126 |
127 | for note in self.model_notes.get_notes():
128 | self.normalized_notes.add(note)
129 |
130 | def try_add(self, anki_note: AnkiNote) -> bool:
131 | if anki_note.content in self.normalized_notes:
132 | return False
133 |
134 | self.model_notes.add_note(anki_note)
135 | self.normalized_notes.add(anki_note.content)
136 | self.added_contents.append(anki_note.content)
137 |
138 | return True
139 |
140 | def write(self, added_notes_file: AddedNotesFile):
141 | added_notes_file.write(self.added_contents)
142 |
--------------------------------------------------------------------------------
/anki_roam_import/model.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from typing import Any, List, Optional, Union
3 |
4 | # PyCharm doesn't infer well with:
5 | # JsonData = Union[None, bool, str, float, int, List['JsonData'], Dict[str, 'JsonData']]
6 | JsonData = Any
7 |
8 |
9 | @dataclass
10 | class RoamBlock:
11 | parts: List['RoamPart']
12 | source: str
13 |
14 |
15 | RoamPart = Union[
16 | str,
17 | 'Cloze',
18 | 'Math',
19 | 'CodeBlock',
20 | 'CodeInline',
21 | 'RoamColonCommand',
22 | 'RoamCurlyCommand',
23 | ]
24 |
25 |
26 | @dataclass
27 | class Cloze:
28 | parts: List['ClozePart']
29 | hint: Optional[str] = None
30 | number: Optional[int] = None
31 |
32 |
33 | ClozePart = Union[str, 'Math', 'CodeBlock', 'CodeInline']
34 |
35 |
36 | @dataclass
37 | class Math:
38 | content: str
39 |
40 |
41 | @dataclass
42 | class CodeBlock:
43 | content: str
44 |
45 |
46 | @dataclass
47 | class CodeInline:
48 | content: str
49 |
50 |
51 | @dataclass
52 | class RoamColonCommand:
53 | command: str
54 | content: str
55 |
56 |
57 | @dataclass
58 | class RoamCurlyCommand:
59 | content: str
60 |
61 |
62 | @dataclass
63 | class AnkiNote:
64 | content: str
65 | source: str
66 |
--------------------------------------------------------------------------------
/anki_roam_import/parser.py:
--------------------------------------------------------------------------------
1 | import re
2 | from dataclasses import dataclass
3 | from typing import (
4 | Any, Callable, Generator, Generic, Iterable, List, Match, Optional, TypeVar,
5 | )
6 |
7 | T = TypeVar('T')
8 |
9 |
10 | @dataclass
11 | class ParsedValue(Generic[T]):
12 | value: T
13 | num_characters: int
14 |
15 |
16 | Parser = Callable[[str, int], ParsedValue[T]]
17 | ParserGenerator = Generator[Parser[Any], Any, T]
18 |
19 |
20 | class ParseError(Exception):
21 | pass
22 |
23 |
24 | def parser_generator(
25 | generator_function: Callable[[], ParserGenerator[T]],
26 | ) -> Parser[T]:
27 | def parser(string: str, start_offset: int) -> ParsedValue[T]:
28 | check_start_offset(string, start_offset)
29 |
30 | generator = generator_function()
31 | offset = start_offset
32 | try:
33 | generated_parser = next(generator)
34 |
35 | while True:
36 | try:
37 | parsed_value = generated_parser(string, offset)
38 | except ParseError as error:
39 | generated_parser = generator.throw(type(error), error)
40 | else:
41 | check_num_characters(parsed_value, string, offset)
42 | offset += parsed_value.num_characters
43 | generated_parser = generator.send(parsed_value.value)
44 |
45 | except StopIteration as stop:
46 | return ParsedValue(stop.value, num_characters=offset - start_offset)
47 |
48 | return parser
49 |
50 |
51 | def check_start_offset(string: str, start_offset: int) -> None:
52 | if start_offset < 0 or start_offset > len(string):
53 | raise ValueError
54 |
55 |
56 | def check_num_characters(
57 | parsed_value: ParsedValue, string: str, start_offset: int,
58 | ) -> None:
59 | bad_num_characters = (
60 | parsed_value.num_characters < 0 or
61 | parsed_value.num_characters > len(string) - start_offset)
62 | if bad_num_characters:
63 | raise ValueError
64 |
65 |
66 | def full_parser(parser: Parser[T]) -> Callable[[str], T]:
67 | def full_parser_function(string: str) -> T:
68 | parsed_value = parser(string, 0)
69 | if parsed_value.num_characters != len(string):
70 | raise ParseError
71 | return parsed_value.value
72 |
73 | return full_parser_function
74 |
75 |
76 | def any_character(string: str, start_offset: int) -> ParsedValue[str]:
77 | check_start_offset(string, start_offset)
78 | if start_offset < len(string):
79 | return ParsedValue(string[start_offset], 1)
80 | raise ParseError
81 |
82 |
83 | def exact_string(string: str) -> Parser[str]:
84 | def parser(string_to_parse: str, start_offset: int) -> ParsedValue[str]:
85 | check_start_offset(string_to_parse, start_offset)
86 | if string_to_parse.startswith(string, start_offset):
87 | return ParsedValue(string, len(string))
88 | raise ParseError
89 |
90 | return parser
91 |
92 |
93 | def regexp(pattern: str, flags=0) -> Parser[Match]:
94 | compiled_pattern = re.compile(pattern, flags)
95 |
96 | def parser(string: str, start_offset: int) -> ParsedValue[Match]:
97 | check_start_offset(string, start_offset)
98 | if match := compiled_pattern.match(string, start_offset):
99 | return ParsedValue(match, len(match.group()))
100 | raise ParseError
101 |
102 | return parser
103 |
104 |
105 | @parser_generator
106 | def nonnegative_integer():
107 | match = yield regexp('[0-9]+')
108 | return int(match[0])
109 |
110 |
111 | def exact_character_once_only(character: str) -> Parser[str]:
112 | assert len(character) == 1
113 |
114 | def parser(string: str, start_offset: int) -> ParsedValue[str]:
115 | check_start_offset(string, start_offset)
116 |
117 | def matches_at(offset: int):
118 | return offset < len(string) and string[offset] == character
119 |
120 | matches = (
121 | matches_at(start_offset) and
122 | not matches_at(start_offset - 1) and
123 | not matches_at(start_offset + 1))
124 |
125 | if matches:
126 | return ParsedValue(character, 1)
127 |
128 | raise ParseError
129 |
130 | return parser
131 |
132 |
133 | def start_of_string(string: str, start_offset: int) -> ParsedValue[None]:
134 | check_start_offset(string, start_offset)
135 | if start_offset == 0:
136 | return ParsedValue(None, 0)
137 | raise ParseError
138 |
139 |
140 | def rest_of_string(string: str, start_offset: int) -> ParsedValue[str]:
141 | check_start_offset(string, start_offset)
142 | rest = string[start_offset:]
143 | return ParsedValue(rest, len(rest))
144 |
145 |
146 | def choose(*parsers: Parser[T]) -> Parser[T]:
147 | @parser_generator
148 | def choose_parser() -> ParserGenerator[T]:
149 | for parser in parsers:
150 | try:
151 | value = yield parser
152 | except ParseError:
153 | continue
154 | else:
155 | return value
156 |
157 | raise ParseError
158 |
159 | return choose_parser
160 |
161 |
162 | def zero_or_more(parser: Parser[T]) -> Parser[List[T]]:
163 | @parser_generator
164 | def zero_or_more_parser() -> ParserGenerator[List[T]]:
165 | values = []
166 |
167 | while True:
168 | try:
169 | value = yield parser
170 | except ParseError:
171 | return values
172 |
173 | values.append(value)
174 |
175 | return zero_or_more_parser
176 |
177 |
178 | def optional(parser: Parser[T]) -> Parser[Optional[T]]:
179 | @parser_generator
180 | def optional_parser():
181 | try:
182 | return (yield parser)
183 | except ParseError:
184 | return None
185 |
186 | return optional_parser
187 |
188 |
189 | def peek(parser: Parser[T]) -> Parser[bool]:
190 | def parse(string: str, start_offset: int) -> ParsedValue[bool]:
191 | check_start_offset(string, start_offset)
192 |
193 | try:
194 | parser(string, start_offset)
195 | except ParseError:
196 | match = False
197 | else:
198 | match = True
199 |
200 | return ParsedValue(match, num_characters=0)
201 |
202 | return parse
203 |
204 |
205 | def delimited_text(open_delimiter: str, close_delimiter: str) -> Parser[str]:
206 | @parser_generator
207 | def parser() -> ParserGenerator[str]:
208 | yield exact_string(open_delimiter)
209 |
210 | characters = []
211 |
212 | while True:
213 | try:
214 | yield exact_string(close_delimiter)
215 | except ParseError:
216 | pass
217 | else:
218 | return ''.join(characters)
219 |
220 | character = yield any_character
221 | characters.append(character)
222 |
223 | return parser
224 |
225 |
226 | def join_strings(value: Iterable[T]) -> List[T]:
227 | values = []
228 |
229 | for sub_value in value:
230 | can_join = (
231 | values and
232 | isinstance(values[-1], str) and
233 | isinstance(sub_value, str))
234 |
235 | if can_join:
236 | values[-1] += sub_value
237 | else:
238 | values.append(sub_value)
239 |
240 | return values
241 |
--------------------------------------------------------------------------------
/anki_roam_import/plugin.py:
--------------------------------------------------------------------------------
1 | from aqt import mw
2 | from aqt.qt import QAction
3 | from aqt.utils import getFile, showInfo
4 |
5 | from .anki import AnkiAddonData, AnkiCollection
6 | from .importer import AnkiNoteImporter
7 |
8 |
9 | def main():
10 | action = QAction('Import Roam notes...', mw)
11 | action.triggered.connect(import_roam_notes_into_anki)
12 | mw.form.menuTools.addAction(action)
13 |
14 |
15 | def import_roam_notes_into_anki():
16 | path = getFile(
17 | mw,
18 | 'Open Roam export',
19 | cb=None,
20 | filter='Roam JSON export (*.zip *.json)',
21 | key='RoamExport',
22 | )
23 |
24 | if not path:
25 | return
26 |
27 | importer = AnkiNoteImporter(AnkiAddonData(mw), AnkiCollection(mw.col))
28 | info = importer.import_from_path(path)
29 | showInfo(info)
30 |
--------------------------------------------------------------------------------
/anki_roam_import/roam.py:
--------------------------------------------------------------------------------
1 | import datetime as dt
2 | import json
3 | import re
4 | from dataclasses import dataclass
5 | from typing import Callable, Iterable, List, Optional, TextIO
6 | from zipfile import ZipFile, is_zipfile
7 |
8 | from .model import (
9 | Cloze, ClozePart, CodeBlock, CodeInline, JsonData, Math, RoamBlock,
10 | RoamColonCommand, RoamCurlyCommand, RoamPart,
11 | )
12 | from .parser import (
13 | ParserGenerator, any_character, choose, delimited_text,
14 | exact_character_once_only, exact_string, full_parser, join_strings,
15 | nonnegative_integer, optional, parser_generator, peek, rest_of_string,
16 | start_of_string, zero_or_more,
17 | )
18 |
19 |
20 | def load_roam_pages(path: str) -> Iterable[JsonData]:
21 | for file in generate_json_files(path):
22 | yield from json.load(file)
23 |
24 |
25 | def generate_json_files(path: str) -> Iterable[TextIO]:
26 | if is_json_path(path):
27 | with open(path, encoding='utf-8') as file:
28 | yield file
29 | elif is_zipfile(path):
30 | with ZipFile(path) as zip_file:
31 | for name in zip_file.namelist():
32 | if is_json_path(name):
33 | with zip_file.open(name) as file:
34 | yield file
35 | else:
36 | raise RuntimeError(f'Unknown file type: {path!r}')
37 |
38 |
39 | def is_json_path(path: str) -> bool:
40 | return path.lower().endswith('.json')
41 |
42 |
43 | @dataclass
44 | class BlockExtractor:
45 | roam_block_builder: 'RoamBlockBuilder'
46 |
47 | def __call__(self, roam_pages: Iterable[JsonData]) -> Iterable[RoamBlock]:
48 | for page in roam_pages:
49 | yield from self.extract_blocks_from_children(page, [])
50 |
51 | def extract_blocks_from_children(
52 | self,
53 | page_or_block: JsonData,
54 | parents: List[JsonData],
55 | ) -> Iterable[RoamBlock]:
56 |
57 | if 'children' not in page_or_block:
58 | return
59 |
60 | parents.append(page_or_block)
61 |
62 | for block in page_or_block['children']:
63 | roam_block = self.roam_block_builder(block, parents)
64 | if roam_block:
65 | yield roam_block
66 |
67 | yield from self.extract_blocks_from_children(block, parents)
68 |
69 | parents.pop()
70 |
71 |
72 | @dataclass
73 | class RoamBlockBuilder:
74 | roam_parser: 'Callable[[str], List[RoamPart]]'
75 | source_builder: 'SourceBuilder'
76 |
77 | def __call__(
78 | self, block: JsonData, parents: List[JsonData],
79 | ) -> Optional[RoamBlock]:
80 | string = block['string']
81 |
82 | if not might_contain_cloze(string):
83 | return None
84 |
85 | parts = self.roam_parser(string)
86 |
87 | if not contains_cloze(parts):
88 | return None
89 |
90 | source = self.source_builder(block, parents)
91 | return RoamBlock(parts, source)
92 |
93 |
94 | def might_contain_cloze(string: str) -> bool:
95 | return '{' in string and '}' in string
96 |
97 |
98 | def contains_cloze(parts: List[RoamPart]) -> bool:
99 | return any(isinstance(part, Cloze) for part in parts)
100 |
101 |
102 | @full_parser
103 | @parser_generator
104 | def parse_roam_block() -> ParserGenerator[List[RoamPart]]:
105 | roam_part = choose(
106 | roam_colon_command,
107 | roam_curly_command,
108 | cloze,
109 | math,
110 | code_block,
111 | code_inline,
112 | any_character,
113 | )
114 | roam_parts = yield zero_or_more(roam_part)
115 | return join_strings(roam_parts)
116 |
117 |
118 | @parser_generator
119 | def roam_colon_command() -> ParserGenerator[RoamCurlyCommand]:
120 | yield start_of_string
121 | yield exact_string(':')
122 |
123 | commands = 'diagram', 'hiccup', 'img', 'q'
124 | command = yield choose(*(exact_string(command) for command in commands))
125 | content = yield rest_of_string
126 |
127 | return RoamColonCommand(command, content)
128 |
129 |
130 | @parser_generator
131 | def roam_curly_command() -> ParserGenerator[RoamCurlyCommand]:
132 | # Roam currently parses differently, e.g.
133 | # {{{}} -> RoamCurlyCommand('{')
134 | # {{}}} -> RoamCurlyCommand('}')
135 | text = yield delimited_text('{{', '}}')
136 | return RoamCurlyCommand(text)
137 |
138 |
139 | @parser_generator
140 | def cloze() -> ParserGenerator[Cloze]:
141 | yield exact_character_once_only('{')
142 | number = yield optional(cloze_number)
143 | content = yield cloze_content
144 | hint = yield optional(cloze_hint)
145 | yield end_of_cloze
146 |
147 | return Cloze(content, hint, number)
148 |
149 |
150 | @parser_generator
151 | def end_of_cloze() -> ParserGenerator[str]:
152 | return (yield exact_character_once_only('}'))
153 |
154 |
155 | @parser_generator
156 | def start_of_hint() -> ParserGenerator[str]:
157 | return (yield exact_string('|'))
158 |
159 |
160 | @parser_generator
161 | def cloze_content() -> ParserGenerator[List[ClozePart]]:
162 | parts = []
163 | while True:
164 | if (yield peek(start_of_hint)) or (yield peek(end_of_cloze)):
165 | return join_strings(parts)
166 |
167 | part = yield choose(
168 | math,
169 | code_block,
170 | code_inline,
171 | any_character,
172 | )
173 | parts.append(part)
174 |
175 |
176 | @parser_generator
177 | def cloze_hint() -> ParserGenerator[str]:
178 | yield exact_string('|')
179 |
180 | characters = []
181 | while True:
182 | if (yield peek(end_of_cloze)):
183 | return ''.join(characters)
184 |
185 | character = yield any_character
186 | characters.append(character)
187 |
188 |
189 | @parser_generator
190 | def cloze_number() -> ParserGenerator[int]:
191 | yield exact_string('c')
192 | number = yield nonnegative_integer
193 | yield exact_string('|')
194 | return number
195 |
196 |
197 | @parser_generator
198 | def math() -> ParserGenerator[Math]:
199 | delimiter = '$$'
200 | text = yield delimited_text(delimiter, delimiter)
201 | return Math(text)
202 |
203 |
204 | @parser_generator
205 | def code_block() -> ParserGenerator[CodeBlock]:
206 | delimiter = '```'
207 | text = yield delimited_text(delimiter, delimiter)
208 | return CodeBlock(text)
209 |
210 |
211 | @parser_generator
212 | def code_inline() -> ParserGenerator[CodeInline]:
213 | delimiter = '`'
214 | text = yield delimited_text(delimiter, delimiter)
215 | return CodeInline(text)
216 |
217 |
218 | @dataclass
219 | class SourceBuilder:
220 | source_finder: 'SourceFinder'
221 | source_formatter: 'SourceFormatter'
222 |
223 | def __call__(self, block: JsonData, parents: List[JsonData]) -> str:
224 | source = self.source_finder(block, parents)
225 | page = parents[0]
226 | return self.source_formatter(block, source, page)
227 |
228 |
229 | @dataclass
230 | class SourceFinder:
231 | source_extractor: 'SourceExtractor'
232 |
233 | def __call__(
234 | self, block: JsonData, parents: List[JsonData],
235 | ) -> Optional[str]:
236 | source = self.find_source_in_children(block)
237 |
238 | if source is None:
239 | source = self.find_source_in_parents(parents)
240 |
241 | return source
242 |
243 | def find_source_in_children(self, block: JsonData) -> Optional[str]:
244 | if 'children' not in block:
245 | return None
246 |
247 | for child in block['children']:
248 | source = self.source_extractor(child)
249 |
250 | if source is not None:
251 | return source
252 |
253 | return None
254 |
255 | def find_source_in_parents(self, parents: List[JsonData]) -> Optional[str]:
256 | for parent in reversed(parents):
257 | source = self.source_extractor(parent)
258 |
259 | if source is not None:
260 | return source
261 |
262 | return None
263 |
264 |
265 | class SourceExtractor:
266 | def __call__(self, block: JsonData) -> Optional[str]:
267 | if 'string' not in block:
268 | return None
269 |
270 | string = block['string']
271 | match = SOURCE_PATTERN.search(string)
272 |
273 | if match:
274 | return match['source']
275 |
276 | return None
277 |
278 |
279 | SOURCE_PATTERN = re.compile(
280 | r'''
281 | ^ # start of string
282 | \s* # leading whitespace
283 | source
284 | (?: \s* : )+ # colons with intervening whitespace
285 | \s* # leading whitespace
286 | (?Pcode
')
197 | format_code_block = code_block_formatter(code_formatter)
198 |
199 | formatted_code = format_code_block(CodeBlock('code'))
200 |
201 | assert formatted_code == 'code
'
202 |
203 |
204 | def test_code_block_formatter_returns_none_if_not_given_code_block_object():
205 | code_formatter = mock(Formatter[str])
206 | format_code_block = code_block_formatter(code_formatter)
207 |
208 | assert format_code_block('') is None
209 |
210 |
211 | def test_code_inline_formatter():
212 | code_formatter = mock(Formatter[str])
213 | when(code_formatter).called_with('code').then_return('code
')
214 | format_code_inline = code_inline_formatter(code_formatter)
215 |
216 | formatted_code = format_code_inline(CodeInline('code'))
217 |
218 | assert formatted_code == 'code
'
219 |
220 |
221 | def test_code_inline_formatter_returns_none_when_not_given_code_inline():
222 | code_formatter = mock(Formatter[str])
223 | format_code_inline = code_inline_formatter(code_formatter)
224 |
225 | assert format_code_inline('') is None
226 |
227 |
228 | def test_format_code_escapes_html_elements():
229 | formatted_code = format_code('<content&>
'
231 |
232 |
233 | def test_format_code_preserves_whitespace():
234 | formatted_code = format_code(' \n ')
235 | assert formatted_code == ' \n
'
236 |
237 |
238 | @pytest.fixture
239 | def format_roam_curly_command(mock_html_formatter):
240 | return roam_curly_command_formatter(mock_html_formatter)
241 |
242 |
243 | def test_format_roam_curly_command(
244 | format_roam_curly_command, mock_html_formatter,
245 | ):
246 | command = RoamCurlyCommand('command')
247 | when(mock_html_formatter).called_with('command').then_return('html command')
248 | assert format_roam_curly_command(command) == '{{html command}}'
249 |
250 |
251 | def test_format_roam_curly_command_returns_none_when_not_given_curly_command(
252 | format_roam_curly_command,
253 | ):
254 | assert format_roam_curly_command('') is None
255 |
256 |
257 | @pytest.fixture
258 | def format_roam_colon_command(mock_html_formatter):
259 | return roam_colon_command_formatter(mock_html_formatter)
260 |
261 |
262 | def test_format_roam_colon_command(
263 | format_roam_colon_command, mock_html_formatter,
264 | ):
265 | when(mock_html_formatter).called_with('command').then_return('html command')
266 | when(mock_html_formatter).called_with('content').then_return('html content')
267 | command = RoamColonCommand('command', 'content')
268 |
269 | assert format_roam_colon_command(command) == ':html commandhtml content'
270 |
271 |
272 | def test_format_roam_colon_command_returns_none_when_not_given_colon_command(
273 | format_roam_colon_command,
274 | ):
275 | assert format_roam_colon_command('') is None
276 |
--------------------------------------------------------------------------------
/tests/test_cloze_enumerator.py:
--------------------------------------------------------------------------------
1 | from dataclasses import replace
2 |
3 | import pytest
4 |
5 | from anki_roam_import.model import Cloze
6 | from anki_roam_import.anki_format import ClozeEnumerator
7 |
8 |
9 | @pytest.fixture
10 | def cloze_enumerator():
11 | return ClozeEnumerator()
12 |
13 |
14 | def test_just_text(cloze_enumerator):
15 | assert list(cloze_enumerator(['text'])) == ['text']
16 |
17 |
18 | def test_just_numbered_cloze(cloze_enumerator):
19 | numbered_cloze = Cloze(['content'], number=2)
20 | assert list(cloze_enumerator([numbered_cloze])) == [numbered_cloze]
21 |
22 |
23 | def test_just_unnumbered_cloze(cloze_enumerator):
24 | unnumbered_cloze = Cloze(['content'])
25 | assert list(cloze_enumerator([unnumbered_cloze])) == [
26 | replace(unnumbered_cloze, number=1)]
27 |
28 |
29 | def test_numbered_then_unnumbered_cloze(cloze_enumerator):
30 | numbered_cloze = Cloze(['content2'], number=2)
31 | unnumbered_cloze = Cloze(['content1'])
32 | result = list(cloze_enumerator([numbered_cloze, unnumbered_cloze]))
33 | assert result == [numbered_cloze, replace(unnumbered_cloze, number=1)]
34 |
35 |
36 | def test_unnumbered_then_numbered_cloze(cloze_enumerator):
37 | unnumbered_cloze = Cloze(['content2'])
38 | numbered_cloze = Cloze(['content1'], number=1)
39 | result = list(cloze_enumerator([unnumbered_cloze, numbered_cloze]))
40 | assert result == [replace(unnumbered_cloze, number=2), numbered_cloze]
41 |
42 |
43 | def test_renumber_invalid_numbered_cloze(cloze_enumerator):
44 | cloze = Cloze(['content'], number=0)
45 | result = list(cloze_enumerator([cloze]))
46 | assert result == [replace(cloze, number=1)]
47 |
--------------------------------------------------------------------------------
/tests/test_importer.py:
--------------------------------------------------------------------------------
1 | import json
2 | from dataclasses import dataclass
3 | from pathlib import Path
4 | from unittest.mock import call
5 |
6 | import pytest
7 |
8 | from anki_roam_import.anki import AnkiAddonData, AnkiCollection, AnkiModelNotes
9 | from anki_roam_import.importer import AnkiNoteImporter
10 | from anki_roam_import.model import AnkiNote, JsonData
11 |
12 | from tests.test_roam import block, page
13 | from tests.util import mock, when
14 |
15 |
16 | @dataclass
17 | class JsonFile:
18 | path: Path
19 |
20 | def write_json(self, json_data: JsonData) -> None:
21 | json.dump(json_data, self.path.open('w', encoding='utf-8'))
22 |
23 | def write_blocks(self, *contents: str, title: str = None) -> None:
24 | blocks = map(block, contents)
25 | self.write_json([page(*blocks, title=title)])
26 |
27 |
28 | @pytest.fixture
29 | def roam_json_file(tmp_path_factory) -> JsonFile:
30 | return JsonFile(tmp_path_factory.mktemp('roam') / 'roam.json')
31 |
32 |
33 | MODEL_NAME = 'model name'
34 | CONTENT_FIELD = 'content field name'
35 | SOURCE_FIELD = 'source field name'
36 | DECK_NAME = 'deck name'
37 |
38 |
39 | @pytest.fixture
40 | def addon_data(tmp_path_factory) -> AnkiAddonData:
41 | anki_addon_data = mock(AnkiAddonData)
42 |
43 | anki_addon_data.read_config.return_value = {
44 | 'model_name': MODEL_NAME,
45 | 'content_field': CONTENT_FIELD,
46 | 'source_field': SOURCE_FIELD,
47 | 'deck_name': DECK_NAME,
48 | }
49 |
50 | user_files_path = tmp_path_factory.mktemp('user_files')
51 | anki_addon_data.user_files_path.return_value = str(user_files_path)
52 |
53 | return anki_addon_data
54 |
55 |
56 | @pytest.fixture
57 | def anki_collection(anki_model_notes) -> AnkiCollection:
58 | collection = mock(AnkiCollection)
59 | (when(collection.get_model_notes)
60 | .called_with(MODEL_NAME, CONTENT_FIELD, SOURCE_FIELD, DECK_NAME)
61 | .then_return(anki_model_notes))
62 | return collection
63 |
64 |
65 | @pytest.fixture
66 | def anki_model_notes() -> AnkiModelNotes:
67 | return mock(AnkiModelNotes)
68 |
69 |
70 | @pytest.fixture
71 | def anki_note_importer(addon_data, anki_collection):
72 | return AnkiNoteImporter(addon_data, anki_collection)
73 |
74 |
75 | def test_import_cloze_note_with_source(
76 | roam_json_file, addon_data, anki_collection, anki_model_notes,
77 | ):
78 | roam_json_file.write_json([page(
79 | block(
80 | '{cloze} text',
81 | block('source:: reference'),
82 | ),
83 | title='title',
84 | )])
85 |
86 | importer = AnkiNoteImporter(addon_data, anki_collection)
87 | info = importer.import_from_path(str(roam_json_file.path))
88 |
89 | anki_model_notes.add_note.assert_has_calls([
90 | call(AnkiNote(
91 | content='{{c1::cloze}} text',
92 | source="referencecode
and {{c1::code
in cloze}}',
126 | source="Note from Roam page 'title'.",
127 | )),
128 | ])
129 | assert info == '1 new notes imported.'
130 |
131 |
132 | def test_do_not_add_note_with_brackets_inside_code(
133 | roam_json_file, anki_note_importer, anki_model_notes,
134 | ):
135 | roam_json_file.write_blocks('```{not cloze}```')
136 |
137 | info = anki_note_importer.import_from_path(str(roam_json_file.path))
138 |
139 | anki_model_notes.add_note.assert_not_called()
140 | assert info == 'No notes found.'
141 |
142 |
143 | def test_format_as_html(
144 | roam_json_file, anki_note_importer, anki_model_notes,
145 | ):
146 | roam_json_file.write_json([page(
147 | block(
148 | '{