├── .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 | (?P .*? ) # source text 287 | \s* # trailing whitespace 288 | $ # end of string 289 | ''', 290 | flags=re.IGNORECASE | re.DOTALL | re.VERBOSE, 291 | ) 292 | 293 | 294 | @dataclass 295 | class SourceFormatter: 296 | time_formatter: 'TimeFormatter' 297 | 298 | def __call__( 299 | self, block: JsonData, source: Optional[str], page: JsonData, 300 | ) -> str: 301 | title = page['title'] 302 | formatted_source = f"Note from Roam page '{title}'" 303 | 304 | if 'create-time' in block: 305 | create_time = self.time_formatter(block['create-time']) 306 | formatted_source += f', created at {create_time}' 307 | 308 | if 'edit-time' in block: 309 | edit_time = self.time_formatter(block['edit-time']) 310 | formatted_source += f', edited at {edit_time}' 311 | 312 | formatted_source += '.' 313 | 314 | if source is not None: 315 | formatted_source = f'{source}\n{formatted_source}' 316 | 317 | return formatted_source 318 | 319 | 320 | @dataclass 321 | class TimeFormatter: 322 | time_zone: Optional[dt.tzinfo] 323 | 324 | def __call__(self, timestamp_millis: int) -> str: 325 | timestamp_seconds = timestamp_millis / 1e3 326 | utc_datetime = dt.datetime.fromtimestamp( 327 | timestamp_seconds, dt.timezone.utc) 328 | local_zone_datetime = utc_datetime.astimezone(self.time_zone) 329 | return local_zone_datetime.isoformat(timespec='milliseconds') 330 | 331 | 332 | extract_roam_blocks = BlockExtractor(RoamBlockBuilder( 333 | parse_roam_block, 334 | SourceBuilder( 335 | SourceFinder(SourceExtractor()), 336 | SourceFormatter(TimeFormatter(time_zone=None)), 337 | ), 338 | )) 339 | -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | from zipfile import ZipFile, ZIP_DEFLATED 4 | 5 | 6 | def main(): 7 | with ZipFile('anki_roam_import.ankiaddon', mode='w', compression=ZIP_DEFLATED) as zip_file: 8 | for path in '__init__.py', 'anki_roam_import', 'config.json', 'config.md', 'manifest.json': 9 | add_to_zip(zip_file, path) 10 | 11 | 12 | def add_to_zip(zip_file: ZipFile, path: str) -> None: 13 | if '__pycache__' in path: 14 | return 15 | 16 | zip_file.write(path) 17 | 18 | if os.path.isdir(path): 19 | for child_path in os.listdir(path): 20 | add_to_zip(zip_file, os.path.join(path, child_path)) 21 | 22 | 23 | main() 24 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "model_name": "Cloze", 3 | "content_field": "Text", 4 | "source_field": null, 5 | "deck_name": null 6 | } -------------------------------------------------------------------------------- /config.md: -------------------------------------------------------------------------------- 1 | `model_name` is the name of the model to use for imported notes. Defaults to 2 | "Cloze". 3 | 4 | `content_field` is the name of the field in which to put the content of the 5 | note. Defaults to "Text". 6 | 7 | `source_field` is the name of the field in which to put the source of the note. 8 | Defaults to null, which means the source is not recorded. 9 | 10 | `deck_name` is the name of the deck in which to put the imported cards. 11 | Defaults to null, which means use the default deck. 12 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "package": "anki_roam_import", 3 | "name": "Anki Roam Import" 4 | } -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | setup( 4 | name='anki-roam-import', 5 | packages=find_packages(exclude=['tests', 'tests.*']), 6 | install_requires=[ 7 | ], 8 | tests_require=['pytest~=5.3.5'], 9 | ) 10 | -------------------------------------------------------------------------------- /tests/test_anki_format.py: -------------------------------------------------------------------------------- 1 | from dataclasses import replace 2 | from typing import Any, Iterable, Optional 3 | 4 | import pytest 5 | 6 | from anki_roam_import.anki_format import ( 7 | AnkiNoteMaker, ClozeEnumerator, Formatter, cloze_formatter, 8 | code_block_formatter, code_inline_formatter, combine_formatters, 9 | format_code, format_text_as_html, math_formatter, 10 | roam_colon_command_formatter, roam_curly_command_formatter, 11 | string_formatter, 12 | ) 13 | from anki_roam_import.model import ( 14 | AnkiNote, Cloze, ClozePart, CodeBlock, CodeInline, Math, RoamBlock, 15 | RoamColonCommand, RoamCurlyCommand, RoamPart, 16 | ) 17 | 18 | from tests.util import mock, when 19 | 20 | 21 | def test_make_note(mock_html_formatter): 22 | cloze = Cloze(['content']) 23 | roam_block = RoamBlock([cloze], 'source') 24 | 25 | cloze_enumerator = mock(ClozeEnumerator) 26 | numbered_cloze = replace(cloze, number=1) 27 | when(cloze_enumerator).called_with([cloze]).then_return([numbered_cloze]) 28 | 29 | roam_parts_formatter = mock(Formatter[Iterable[RoamPart]]) 30 | formatted_note = '{{c1::content}}' 31 | (when(roam_parts_formatter) 32 | .called_with([numbered_cloze]) 33 | .then_return(formatted_note)) 34 | 35 | when(mock_html_formatter).called_with('source').then_return('html source') 36 | 37 | note_maker = AnkiNoteMaker( 38 | cloze_enumerator, roam_parts_formatter, mock_html_formatter, 39 | ) 40 | 41 | assert note_maker(roam_block) == AnkiNote(formatted_note, 'html source') 42 | 43 | 44 | def test_use_first_formatter_that_returns_string(): 45 | # noinspection PyUnusedLocal 46 | def first_formatter(value: Any) -> Optional[str]: 47 | return None 48 | 49 | # noinspection PyUnusedLocal 50 | def second_formatter(value: Any) -> Optional[str]: 51 | return 'formatted string' 52 | 53 | formatter = combine_formatters(first_formatter, second_formatter) 54 | 55 | assert formatter('string') == 'formatted string' 56 | 57 | 58 | def test_raise_value_error_if_no_formatter_matches(): 59 | # noinspection PyUnusedLocal 60 | def none_formatter(value: Any) -> Optional[str]: 61 | return None 62 | 63 | formatter = combine_formatters(none_formatter) 64 | 65 | with pytest.raises(ValueError): 66 | formatter('string') 67 | 68 | 69 | @pytest.fixture 70 | def format_cloze( 71 | mock_cloze_part_formatter, mock_html_formatter, 72 | ) -> Formatter[Cloze]: 73 | return cloze_formatter(mock_cloze_part_formatter, mock_html_formatter) 74 | 75 | 76 | @pytest.fixture 77 | def mock_cloze_part_formatter() -> Formatter[ClozePart]: 78 | return mock(Formatter[ClozePart]) 79 | 80 | 81 | @pytest.fixture 82 | def mock_html_formatter() -> Formatter[str]: 83 | return mock(Formatter[str]) 84 | 85 | 86 | def test_cloze_formatter_joins_cloze_parts( 87 | format_cloze, mock_cloze_part_formatter, 88 | ): 89 | (when(mock_cloze_part_formatter) 90 | .called_with('part 1') 91 | .then_return('formatted part 1')) 92 | (when(mock_cloze_part_formatter) 93 | .called_with('part 2') 94 | .then_return('formatted part 2')) 95 | cloze = Cloze(['part 1', 'part 2'], number=1) 96 | 97 | formatted_cloze = format_cloze(cloze) 98 | 99 | assert formatted_cloze == '{{c1::formatted part 1formatted part 2}}' 100 | 101 | 102 | def test_cloze_formatter_formats_cloze_number( 103 | format_cloze, mock_cloze_part_formatter, 104 | ): 105 | cloze = Cloze(['part'], number=2) 106 | (when(mock_cloze_part_formatter) 107 | .called_with('part') 108 | .then_return('formatted part')) 109 | 110 | formatted_cloze = format_cloze(cloze) 111 | assert formatted_cloze == '{{c2::formatted part}}' 112 | 113 | 114 | def test_cloze_formatter_formats_hint( 115 | format_cloze, mock_cloze_part_formatter, mock_html_formatter, 116 | ): 117 | cloze = Cloze(['part'], 'hint', number=1) 118 | (when(mock_cloze_part_formatter) 119 | .called_with('part') 120 | .then_return('formatted part')) 121 | (when(mock_html_formatter) 122 | .called_with('hint') 123 | .then_return('html hint')) 124 | 125 | assert format_cloze(cloze) == '{{c1::formatted part::html hint}}' 126 | 127 | 128 | def test_cloze_formatter_raises_value_error_on_unnumbered_cloze(format_cloze): 129 | with pytest.raises(ValueError): 130 | format_cloze(Cloze(['part'])) 131 | 132 | 133 | def test_cloze_formatter_returns_none_if_not_given_cloze(format_cloze): 134 | assert format_cloze('') is None 135 | 136 | 137 | def test_cloze_formatter_raises_value_error_on_zero_numbered_cloze( 138 | format_cloze, 139 | ): 140 | with pytest.raises(ValueError): 141 | format_cloze(Cloze(['part'], number=0)) 142 | 143 | 144 | def test_format_text_as_html_returns_equal_string_without_html(): 145 | assert format_text_as_html('string') == 'string' 146 | 147 | 148 | def test_format_text_as_html_escapes_html_elements(): 149 | assert format_text_as_html('<&>') == '<&>' 150 | 151 | 152 | def test_format_text_as_html_leaves_single_spaces_as_spaces(): 153 | assert format_text_as_html('a b') == 'a b' 154 | 155 | 156 | def test_format_text_as_html_converts_consecutive_spaces_to_nbsp(): 157 | assert format_text_as_html('a b') == 'a  b' 158 | 159 | 160 | def test_format_text_as_html_converts_newline_to_br(): 161 | assert format_text_as_html('a\nb') == 'a
b' 162 | 163 | 164 | @pytest.fixture 165 | def format_string(mock_html_formatter): 166 | return string_formatter(mock_html_formatter) 167 | 168 | 169 | def test_format_string_returns_html_formatted_string( 170 | format_string, mock_html_formatter, 171 | ): 172 | when(mock_html_formatter).called_with('string').then_return('html string') 173 | assert format_string('string') == 'html string' 174 | 175 | 176 | def test_format_string_returns_none_if_not_given_string(format_string): 177 | assert format_string(0) is None 178 | 179 | 180 | @pytest.fixture 181 | def format_math(mock_html_formatter): 182 | return math_formatter(mock_html_formatter) 183 | 184 | 185 | def test_format_math_formats_math_object(format_math, mock_html_formatter): 186 | when(mock_html_formatter).called_with('math').then_return('html math') 187 | assert format_math(Math('math')) == r'\(html math\)' 188 | 189 | 190 | def test_format_math_returns_none_if_not_given_math_object(format_math): 191 | assert format_math('') is None 192 | 193 | 194 | def test_code_block_formatter(): 195 | code_formatter = mock(Formatter[str]) 196 | when(code_formatter).called_with('code').then_return('code') 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('') 230 | assert formatted_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="reference
Note from Roam page 'title'.", 93 | )), 94 | ]) 95 | assert info == '1 new notes imported.' 96 | 97 | 98 | def test_translate_latex_math( 99 | roam_json_file, anki_note_importer, anki_model_notes, 100 | ): 101 | roam_json_file.write_blocks( 102 | r'$$\textrm{outside cloze}$$ and {inside $$\textrm{cloze}$$}', 103 | ) 104 | 105 | info = anki_note_importer.import_from_path(str(roam_json_file.path)) 106 | 107 | anki_model_notes.add_note.assert_has_calls([ 108 | call(AnkiNote( 109 | content=r'\(\textrm{outside cloze}\) and {{c1::inside \(\textrm{cloze}\)}}', 110 | source="Note from Roam page 'title'.", 111 | )), 112 | ]) 113 | assert info == '1 new notes imported.' 114 | 115 | 116 | def test_translate_code( 117 | roam_json_file, anki_note_importer, anki_model_notes, 118 | ): 119 | roam_json_file.write_blocks('`code` and {`code` in cloze}') 120 | 121 | info = anki_note_importer.import_from_path(str(roam_json_file.path)) 122 | 123 | anki_model_notes.add_note.assert_has_calls([ 124 | call(AnkiNote( 125 | content='code 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 | '{ } & text ', 149 | block('source:: source & '), 150 | ), 151 | title=' & title ', 152 | )]) 153 | 154 | info = anki_note_importer.import_from_path(str(roam_json_file.path)) 155 | 156 | anki_model_notes.add_note.assert_has_calls([ 157 | call(AnkiNote( 158 | content='{{c1::<cloze> }} &  text ', 159 | source="source  &
Note from Roam page ' &  title '.", 160 | )), 161 | ]) 162 | assert info == '1 new notes imported.' 163 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from anki_roam_import.parser import ( 4 | ParseError, ParsedValue, Parser, ParserGenerator, any_character, choose, 5 | exact_string, full_parser, parser_generator, zero_or_more, 6 | ) 7 | 8 | from tests.util import mock, when 9 | 10 | 11 | def test_parser_generator_passes_through_parsed_value(): 12 | parsed_value = ParsedValue('value', 3) 13 | parser = mock(Parser) 14 | when(parser).called_with('string', 2).then_return(parsed_value) 15 | 16 | @parser_generator 17 | def pass_through_parser() -> ParserGenerator[str]: 18 | return (yield parser) 19 | 20 | assert pass_through_parser('string', 2) == parsed_value 21 | 22 | 23 | def test_parser_generator_passes_through_parse_error(): 24 | @parser_generator 25 | def pass_through_parser() -> ParserGenerator: 26 | return (yield error_parser) 27 | 28 | with pytest.raises(ParseError): 29 | pass_through_parser('string', 0) 30 | 31 | 32 | def test_parser_generator_raises_value_error_if_start_offset_negative(): 33 | parser = mock(Parser) 34 | 35 | @parser_generator 36 | def pass_through_parser() -> ParserGenerator[str]: 37 | return (yield parser) 38 | 39 | with pytest.raises(ValueError): 40 | pass_through_parser('string', -1) 41 | 42 | 43 | def test_parser_generator_raised_value_error_if_start_offset_too_large(): 44 | parser = mock(Parser) 45 | 46 | @parser_generator 47 | def pass_through_parser() -> ParserGenerator[str]: 48 | return (yield parser) 49 | 50 | with pytest.raises(ValueError): 51 | pass_through_parser('string', len('string') + 1) 52 | 53 | 54 | def test_parser_generator_allows_start_offset_at_end_of_string(): 55 | parsed_value = ParsedValue('value', 0) 56 | parser = mock(Parser) 57 | when(parser).called_with('string', len('string')).then_return(parsed_value) 58 | 59 | @parser_generator 60 | def pass_through_parser() -> ParserGenerator[str]: 61 | return (yield parser) 62 | 63 | assert pass_through_parser('string', len('string')) == parsed_value 64 | 65 | 66 | def test_parser_generator_raises_value_error_if_num_characters_negative(): 67 | parser = mock(Parser) 68 | parser.return_value = ParsedValue('value', -1) 69 | 70 | @parser_generator 71 | def pass_through_parser() -> ParserGenerator[str]: 72 | return (yield parser) 73 | 74 | with pytest.raises(ValueError): 75 | pass_through_parser('string', 0) 76 | 77 | 78 | def test_parser_generator_raises_value_error_when_num_characters_too_large(): 79 | parser = mock(Parser) 80 | parser.return_value = ParsedValue('value', len('string')) 81 | 82 | @parser_generator 83 | def pass_through_parser() -> ParserGenerator[str]: 84 | return (yield parser) 85 | 86 | with pytest.raises(ValueError): 87 | pass_through_parser('string', 1) 88 | 89 | 90 | def test_parser_generator_throws_parse_error_back(): 91 | @parser_generator 92 | def parser() -> ParserGenerator[str]: 93 | try: 94 | yield error_parser 95 | except ParseError: 96 | return 'value' 97 | 98 | assert parser('', 0) == ParsedValue('value', 0) 99 | 100 | 101 | def test_parser_generator_send_parsed_value_back(): 102 | parser = mock(Parser) 103 | parser.return_value = ParsedValue('value', 1) 104 | 105 | @parser_generator 106 | def pass_through_parser() -> ParserGenerator[str]: 107 | value = yield parser 108 | if value == 'value': 109 | return 'success' 110 | 111 | assert pass_through_parser('string', 2) == ParsedValue('success', 1) 112 | 113 | 114 | def test_parser_generator_iterates_through_parsers(): 115 | first_parser = mock(Parser) 116 | (when(first_parser) 117 | .called_with('string', 0) 118 | .then_return(ParsedValue('first value', 1))) 119 | 120 | second_parser = mock(Parser) 121 | (when(second_parser) 122 | .called_with('string', 1) 123 | .then_return(ParsedValue('second value', 2))) 124 | 125 | @parser_generator 126 | def parser() -> ParserGenerator[str]: 127 | first_value = yield first_parser 128 | second_value = yield second_parser 129 | return first_value, second_value 130 | 131 | parsed_value = parser('string', 0) 132 | assert parsed_value == ParsedValue(('first value', 'second value'), 3) 133 | 134 | 135 | def test_full_parser_parses_full_string(): 136 | @full_parser 137 | def parser(string: str, start_offset: int) -> ParsedValue[str]: 138 | return ParsedValue('value', len(string)) 139 | 140 | assert parser('string') == 'value' 141 | 142 | 143 | def test_full_parser_raises_parse_error_if_full_string_not_parsed(): 144 | @full_parser 145 | def parser(string: str, start_offset: int) -> ParsedValue[str]: 146 | return ParsedValue('value', len(string) - 1) 147 | 148 | with pytest.raises(ParseError): 149 | parser('string') 150 | 151 | 152 | def test_any_character_parses_next_character(): 153 | assert any_character('string', 0) == ParsedValue('s', 1) 154 | assert any_character('string', 2) == ParsedValue('r', 1) 155 | 156 | 157 | def test_any_character_raises_parse_error_at_end_of_string(): 158 | with pytest.raises(ParseError): 159 | any_character('string', len('string')) 160 | 161 | with pytest.raises(ParseError): 162 | any_character('', 0) 163 | 164 | 165 | def test_any_character_raises_value_error_if_start_offset_negative(): 166 | with pytest.raises(ValueError): 167 | any_character('string', -1) 168 | 169 | 170 | def test_any_character_raises_value_error_if_start_offset_too_large(): 171 | with pytest.raises(ValueError): 172 | any_character('string', len('string') + 1) 173 | 174 | 175 | def test_exact_string_parses_matching_string_from_offset(): 176 | parser = exact_string('string') 177 | assert parser('astring', 1) == ParsedValue('string', len('string')) 178 | 179 | 180 | def test_exact_string_raises_parse_error_if_string_does_not_match(): 181 | parser = exact_string('string') 182 | with pytest.raises(ParseError): 183 | parser('string', 1) 184 | 185 | 186 | def test_exact_string_raises_value_error_if_start_offset_negative(): 187 | parser = exact_string('string') 188 | with pytest.raises(ValueError): 189 | parser('string', -1) 190 | 191 | 192 | def test_exact_string_raises_value_error_if_start_offset_too_large(): 193 | parser = exact_string('string') 194 | with pytest.raises(ValueError): 195 | parser('string', len('string') + 1) 196 | 197 | 198 | def test_return_empty_list_when_no_match(): 199 | parser = zero_or_more(error_parser) 200 | 201 | assert parser('string', 0) == ParsedValue([], 0) 202 | 203 | 204 | def test_return_list_containing_each_match(): 205 | sub_parser = mock(Parser) 206 | when(sub_parser).called_with('aab', 0).then_return(ParsedValue('A', 2)) 207 | when(sub_parser).called_with('aab', 2).then_return(ParsedValue('B', 1)) 208 | when(sub_parser).called_with('aab', 3).then_raise(ParseError) 209 | 210 | parser = zero_or_more(sub_parser) 211 | 212 | assert parser('aab', 0) == ParsedValue(['A', 'B'], 3) 213 | 214 | 215 | def test_return_none_when_no_choice_matches(): 216 | parser = choose(error_parser) 217 | 218 | with pytest.raises(ParseError): 219 | parser('string', 0) 220 | 221 | 222 | def test_return_first_matching_choice(): 223 | second_choice = mock(Parser) 224 | parser = choose(error_parser, second_choice) 225 | parsed_value = ParsedValue('s', 1) 226 | when(second_choice).called_with('string', 0).then_return(parsed_value) 227 | 228 | assert parser('string', 0) == parsed_value 229 | 230 | 231 | def error_parser(string: str, index: int) -> ParsedValue[str]: 232 | raise ParseError 233 | -------------------------------------------------------------------------------- /tests/test_roam.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List 2 | 3 | import pytest 4 | 5 | from anki_roam_import.model import Cloze, JsonData, RoamBlock, RoamPart 6 | from anki_roam_import.roam import ( 7 | BlockExtractor, RoamBlockBuilder, SourceBuilder, 8 | ) 9 | from tests.util import mock, when 10 | 11 | 12 | @pytest.fixture 13 | def note_extractor(mock_roam_note_builder) -> BlockExtractor: 14 | return BlockExtractor(mock_roam_note_builder) 15 | 16 | 17 | @pytest.fixture 18 | def mock_roam_note_builder() -> RoamBlockBuilder: 19 | return mock(RoamBlockBuilder) 20 | 21 | 22 | def test_extract_from_empty_roam_json(note_extractor): 23 | roam_json = [] 24 | 25 | assert list(note_extractor(roam_json)) == [] 26 | 27 | 28 | def test_extract_from_empty_page(note_extractor): 29 | roam_json = [page()] 30 | 31 | assert list(note_extractor(roam_json)) == [] 32 | 33 | 34 | def test_extract_from_single_block(note_extractor, mock_roam_note_builder): 35 | content = '{note}' 36 | block_json = block(content) 37 | page_json = page(block_json) 38 | roam_note = RoamBlock([Cloze(['cloze content'])], 'source') 39 | 40 | (when(mock_roam_note_builder) 41 | .called_with(block_json, [page_json]) 42 | .then_return(roam_note)) 43 | 44 | assert list(note_extractor([page_json])) == [roam_note] 45 | 46 | 47 | def test_extract_from_nested_block(note_extractor, mock_roam_note_builder): 48 | child_block_json = block('{child}') 49 | parent_block_json = block('{parent}', child_block_json) 50 | page_json = page(parent_block_json) 51 | child_roam_note = RoamBlock( 52 | [Cloze(['child cloze content'])], 'child source') 53 | parent_roam_note = RoamBlock( 54 | [Cloze(['parent cloze content'])], 'parent source') 55 | 56 | (when(mock_roam_note_builder) 57 | .called_with(parent_block_json, [page_json]) 58 | .then_return(parent_roam_note)) 59 | 60 | (when(mock_roam_note_builder) 61 | .called_with(child_block_json, [page_json, parent_block_json]) 62 | .then_return(child_roam_note)) 63 | 64 | assert list(note_extractor([page_json])) == [ 65 | parent_roam_note, child_roam_note] 66 | 67 | 68 | def test_extract_without_note(note_extractor, mock_roam_note_builder): 69 | content = 'no note' 70 | block_json = block(content) 71 | page_json = page(block_json) 72 | 73 | (when(mock_roam_note_builder) 74 | .called_with(block_json, [page_json]) 75 | .then_return(None)) 76 | 77 | assert list(note_extractor([page_json])) == [] 78 | 79 | 80 | @pytest.fixture 81 | def roam_note_builder( 82 | mock_roam_parser, mock_source_builder, 83 | ) -> RoamBlockBuilder: 84 | return RoamBlockBuilder(mock_roam_parser, mock_source_builder) 85 | 86 | 87 | @pytest.fixture 88 | def mock_roam_parser() -> Callable[[str], List[RoamPart]]: 89 | return mock(Callable[[str], List[RoamPart]]) 90 | 91 | 92 | @pytest.fixture 93 | def mock_source_builder() -> SourceBuilder: 94 | return mock(SourceBuilder) 95 | 96 | 97 | def test_return_none_when_no_cloze_part(roam_note_builder, mock_roam_parser): 98 | block_json = block('no note') 99 | parent_json = page(block_json) 100 | when(mock_roam_parser).called_with('no note').then_return(['no note']) 101 | 102 | assert roam_note_builder(block_json, [parent_json]) is None 103 | 104 | 105 | def test_return_note_when_cloze_part( 106 | roam_note_builder, mock_roam_parser, mock_source_builder, 107 | ): 108 | block_json = block('{block text}') 109 | parent_json = page(block_json) 110 | note_parts = [Cloze(['cloze content'])] 111 | when(mock_roam_parser).called_with('{block text}').then_return(note_parts) 112 | (when(mock_source_builder) 113 | .called_with(block_json, [parent_json]) 114 | .then_return('source')) 115 | 116 | roam_note = roam_note_builder(block_json, [parent_json]) 117 | 118 | assert roam_note == RoamBlock(note_parts, 'source') 119 | 120 | 121 | def block( 122 | string: str, 123 | *children: JsonData, 124 | create_time: int = None, 125 | edit_time: int = None, 126 | ) -> JsonData: 127 | block_json = {'string': string} 128 | 129 | if children: 130 | block_json['children'] = list(children) 131 | 132 | if create_time is not None: 133 | block_json['create-time'] = create_time 134 | 135 | if edit_time is not None: 136 | block_json['edit-time'] = edit_time 137 | 138 | return block_json 139 | 140 | 141 | def page(*blocks: JsonData, title: str = None) -> JsonData: 142 | if title is None: 143 | title = 'title' 144 | 145 | page_json = {'title': title} 146 | 147 | if blocks: 148 | page_json['children'] = list(blocks) 149 | 150 | return page_json 151 | -------------------------------------------------------------------------------- /tests/test_roam_parser.py: -------------------------------------------------------------------------------- 1 | from anki_roam_import.model import ( 2 | Cloze, CodeBlock, CodeInline, Math, RoamColonCommand, RoamCurlyCommand, 3 | ) 4 | from anki_roam_import.roam import parse_roam_block 5 | 6 | 7 | def test_parse_string(): 8 | assert parse_roam_block('text') == ['text'] 9 | 10 | 11 | def test_parse_cloze(): 12 | assert parse_roam_block('{cloze}') == [Cloze(['cloze'])] 13 | 14 | 15 | def test_simple_note_with_double_brackets(): 16 | assert parse_roam_block('{{content}}') == [RoamCurlyCommand('content')] 17 | 18 | 19 | def test_simple_note_with_malformed_double_brackets(): 20 | assert parse_roam_block('{ {content}}') == ['{ {content}}'] 21 | 22 | 23 | def test_simple_note_with_single_brackets_inside_double_brackets(): 24 | text = ' query: {and: [[TODO]]} ' 25 | assert parse_roam_block('{{' + text + '}}') == [RoamCurlyCommand(text)] 26 | 27 | 28 | def test_colon_command_with_curly_brackets(): 29 | assert parse_roam_block(':hiccup {text}') == [ 30 | RoamColonCommand('hiccup', ' {text}')] 31 | 32 | 33 | def test_colon_command_with_space_before(): 34 | text = ' :hiccup text' 35 | assert parse_roam_block(text) == [text] 36 | 37 | 38 | def test_colon_command_with_space_after(): 39 | text = ': hiccup text' 40 | assert parse_roam_block(text) == [text] 41 | 42 | 43 | def test_colon_command_with_non_whitespace_text_immediately_after(): 44 | assert parse_roam_block(':hiccuptext') == [ 45 | RoamColonCommand('hiccup', 'text')] 46 | 47 | 48 | def test_code_inline(): 49 | assert parse_roam_block('`{code}`') == [CodeInline('{code}')] 50 | 51 | 52 | def test_code_block(): 53 | assert parse_roam_block('```{code}```') == [CodeBlock('{code}')] 54 | 55 | 56 | def test_simple_note_with_newline(): 57 | assert parse_roam_block('{con\ntent}') == [Cloze(['con\ntent'])] 58 | 59 | 60 | def test_simple_note_with_hint(): 61 | assert parse_roam_block('{content|hint}') == [Cloze(['content'], 'hint')] 62 | 63 | 64 | def test_note_with_cloze_number(): 65 | assert parse_roam_block('{c0|content}') == [Cloze(['content'], number=0)] 66 | 67 | 68 | def test_note_with_cloze_number_and_hint(): 69 | assert parse_roam_block('{c0|content|hint}') == [ 70 | Cloze(['content'], 'hint', number=0)] 71 | 72 | 73 | def test_note_with_cloze_then_text(): 74 | assert parse_roam_block('{c1|content} text') == [ 75 | Cloze(['content'], number=1), ' text'] 76 | 77 | 78 | def test_note_with_text_then_cloze(): 79 | assert parse_roam_block('text{c1|content}') == [ 80 | 'text', Cloze(['content'], number=1)] 81 | 82 | 83 | def test_parse_math_part(): 84 | assert parse_roam_block(r'$$\textrm{math}$$') == [Math(r'\textrm{math}')] 85 | 86 | 87 | def test_parse_cloze_containing_math(): 88 | parts = parse_roam_block(r'{$$\textrm{math}$$}') 89 | assert parts == [Cloze([Math(r'\textrm{math}')])] 90 | 91 | 92 | def test_parse_cloze_containing_code_inline(): 93 | parts = parse_roam_block('{`code``code`}') 94 | assert parts == [Cloze([CodeInline('code'), CodeInline('code')])] 95 | 96 | 97 | def test_parse_cloze_containing_code_block(): 98 | parts = parse_roam_block('{```co``de```}') 99 | assert parts == [Cloze([CodeBlock('co``de')])] 100 | -------------------------------------------------------------------------------- /tests/test_source_builder.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | import pytest 4 | 5 | from anki_roam_import.roam import ( 6 | SourceBuilder, SourceExtractor, SourceFinder, 7 | SourceFormatter, TimeFormatter, 8 | ) 9 | 10 | from tests.test_roam import block, page 11 | from tests.util import mock, when 12 | 13 | 14 | @pytest.fixture 15 | def source_builder(mock_source_finder, mock_source_formatter) -> SourceBuilder: 16 | return SourceBuilder(mock_source_finder, mock_source_formatter) 17 | 18 | 19 | @pytest.fixture 20 | def mock_source_finder() -> SourceFinder: 21 | return mock(SourceFinder) 22 | 23 | 24 | @pytest.fixture 25 | def mock_source_formatter() -> SourceFormatter: 26 | return mock(SourceFormatter) 27 | 28 | 29 | def test_source_builder( 30 | source_builder, mock_source_finder, mock_source_formatter 31 | ): 32 | child_block_json = block('child') 33 | parent_block_json = block('parent', child_block_json) 34 | page_json = page(parent_block_json) 35 | 36 | (when(mock_source_finder) 37 | .called_with(child_block_json, [page_json, parent_block_json]) 38 | .then_return('found source')) 39 | 40 | (when(mock_source_formatter) 41 | .called_with(child_block_json, 'found source', page_json) 42 | .then_return('formatted source')) 43 | 44 | formatted_source = source_builder( 45 | child_block_json, [page_json, parent_block_json]) 46 | 47 | assert formatted_source == 'formatted source' 48 | 49 | 50 | @pytest.fixture 51 | def source_finder() -> SourceFinder: 52 | def source_extractor(block_json): 53 | if 'with source' in block_json['string']: 54 | return block_json['string'] 55 | return None 56 | 57 | return SourceFinder(source_extractor) 58 | 59 | 60 | def test_find_source_ignores_grandchildren(source_finder): 61 | grandchild = block('grandchild with source') 62 | child = block('child', grandchild) 63 | self = block('self', child) 64 | 65 | assert source_finder(self, []) is None 66 | 67 | 68 | def test_find_source_prefers_first_child_with_source_to_parent( 69 | source_finder, 70 | ): 71 | self = block( 72 | 'self', 73 | block('first child'), 74 | block('first child with source'), 75 | block('second child with source'), 76 | ) 77 | parent = block( 78 | 'parent with source', 79 | block('earlier sibling with source'), 80 | self, 81 | block('later sibling with source'), 82 | ) 83 | 84 | assert source_finder(self, [parent]) == 'first child with source' 85 | 86 | 87 | def test_find_source_ignores_siblings_with_source(source_finder): 88 | self = block('self') 89 | parent = block( 90 | 'parent without source', 91 | block('earlier sibling with source'), 92 | self, 93 | block('later sibling with source'), 94 | ) 95 | 96 | assert source_finder(self, [parent]) is None 97 | 98 | 99 | def test_find_source_prefers_nearer_parent_with_source(source_finder): 100 | self = block('self') 101 | parent = block('parent', self) 102 | grandparent = block('grandparent with source', parent) 103 | great_grandparent = block('great grandparent with source', grandparent) 104 | 105 | source = source_finder(self, [great_grandparent, grandparent, parent]) 106 | 107 | assert source == 'grandparent with source' 108 | 109 | 110 | @pytest.fixture 111 | def source_extractor() -> SourceExtractor: 112 | return SourceExtractor() 113 | 114 | 115 | def test_source_extractor_without_string(source_extractor): 116 | assert source_extractor({}) is None 117 | 118 | 119 | def test_source_extractor_with_upper_case_source(source_extractor): 120 | block_json = block('Source:: reference') 121 | assert source_extractor(block_json) == 'reference' 122 | 123 | 124 | def test_source_extractor_with_lower_case_source(source_extractor): 125 | block_json = block('source:: reference') 126 | assert source_extractor(block_json) == 'reference' 127 | 128 | 129 | def test_source_extractor_with_one_colon(source_extractor): 130 | block_json = block('Source: reference') 131 | assert source_extractor(block_json) == 'reference' 132 | 133 | 134 | def test_source_extractor_with_extra_whitespace(source_extractor): 135 | block_json = block(' Source : : reference ') 136 | assert source_extractor(block_json) == 'reference' 137 | 138 | 139 | def test_source_extractor_with_extra_text(source_extractor): 140 | block_json = block('text source: reference ') 141 | assert source_extractor(block_json) is None 142 | 143 | 144 | @pytest.fixture 145 | def source_formatter(mock_time_formatter) -> SourceFormatter: 146 | return SourceFormatter(mock_time_formatter) 147 | 148 | 149 | @pytest.fixture 150 | def mock_time_formatter() -> TimeFormatter: 151 | return mock(TimeFormatter) 152 | 153 | 154 | def test_format_source(source_formatter, mock_time_formatter): 155 | create_time = 1337 156 | edit_time = 31337 157 | page_json = page(title='title') 158 | block_json = block('note', create_time=create_time, edit_time=edit_time) 159 | (when(mock_time_formatter) 160 | .called_with(create_time) 161 | .then_return('[create time]')) 162 | (when(mock_time_formatter) 163 | .called_with(edit_time) 164 | .then_return('[edit time]')) 165 | 166 | formatted_source = source_formatter(block_json, '[source]', page_json) 167 | 168 | assert formatted_source == "[source]\nNote from Roam page 'title', created at [create time], edited at [edit time]." 169 | 170 | 171 | def test_time_formatter(): 172 | time_formatter = TimeFormatter(dt.timezone.utc) 173 | assert time_formatter(1543212345678) == '2018-11-26T06:05:45.678+00:00' 174 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | from inspect import signature 2 | from typing import Type, TypeVar 3 | from unittest.mock import DEFAULT, create_autospec 4 | 5 | 6 | __all__ = ['mock', 'when'] 7 | 8 | 9 | T = TypeVar('T') 10 | 11 | 12 | def mock(spec: Type[T], **kwargs) -> T: 13 | return create_autospec(spec, spec_set=True, instance=True, **kwargs) 14 | 15 | 16 | # noinspection PyPep8Naming 17 | class when: 18 | def __init__(self, mock_object): 19 | self.mock_object = mock_object 20 | 21 | def called_with(self, *args, **kwargs): 22 | return CalledWith(self.mock_object, *args, **kwargs) 23 | 24 | 25 | class CalledWith: 26 | def __init__(self, mock_object, *args, **kwargs): 27 | self.mock_object = mock_object 28 | self.signature = signature(self.mock_object) 29 | self.arguments = self._normalized_arguments(*args, **kwargs) 30 | 31 | def _normalized_arguments(self, *args, **kwargs): 32 | bound_arguments = self.signature.bind(*args, **kwargs) 33 | bound_arguments.apply_defaults() 34 | return dict(bound_arguments.arguments) 35 | 36 | def then_return(self, value): 37 | def side_effect(): 38 | return value 39 | self._add_new_side_effect(side_effect) 40 | 41 | def then_raise(self, exception): 42 | def side_effect(): 43 | raise exception 44 | self._add_new_side_effect(side_effect) 45 | 46 | def _add_new_side_effect(self, new_side_effect): 47 | original_side_effect = self.mock_object.side_effect 48 | 49 | if callable(original_side_effect): 50 | fall_back = original_side_effect 51 | elif original_side_effect is None: 52 | def fall_back(*args, **kwargs): 53 | return DEFAULT 54 | else: 55 | raise ValueError 56 | 57 | def side_effect(*args, **kwargs): 58 | if self._normalized_arguments(*args, **kwargs) == self.arguments: 59 | return new_side_effect() 60 | return fall_back(*args, **kwargs) 61 | 62 | self.mock_object.side_effect = side_effect 63 | --------------------------------------------------------------------------------