Skip to content

Commit baa7591

Browse files
author
Rubaiyat Khondaker
authored
Merge pull request #2 from Pseudonium/develop
Develop - Note Update
2 parents 7865304 + f00415f commit baa7591

2 files changed

Lines changed: 179 additions & 68 deletions

File tree

README.md

Lines changed: 80 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,68 +2,110 @@
22
Script to add flashcards from an Obsidian markdown file to Anki.
33

44
## Setup
5-
Download the script from the repository. You may wish to consider placing it in a Scripts folder, and adding the script to your PATH.
6-
You'll need to ensure that Anki is running on your desired profile, and that you've installed [AnkiConnect](https://github.com/FooSoft/anki-connect).
7-
8-
Once you've placed the script in the desired directory, run it once with no arguments:
9-
`obsidian_to_anki.py`
10-
11-
This will make a configuration file, `obsidian_to_anki_config.ini`.
5+
1. Install [Python](https://www.python.org/downloads/)
6+
2. Download the desired release.
7+
3. Place the script "obsidian_to_anki.py" in a convenient folder. You may wish to consider placing it in a Scripts folder, and adding the folder to your PATH
8+
4. Start up Anki, and navigate to your desired profile
9+
5. Ensure that you've installed [AnkiConnect](https://github.com/FooSoft/anki-connect).
10+
6. From the command line, run the script once with no arguments - `{Path to script}/obsidian_to_anki.py`
11+
This will make a configuration file in the same directory as the script, "obsidian_to_anki_config.ini".
1212

1313
## Usage
1414
For simple documentation, run the script with the `-h` flag.
1515

16-
Note that you need to have Anki running when using the script.
16+
To edit the config file, run `obsidian_to_anki.py -c`. This will attempt to open the config file for editing, but isn't guaranteed to work. If it doesn't work, you'll have to navigate to the config file and edit it manually. For more information, see [Config](#config)
17+
18+
**All other operations of the script require Anki to be running.**
19+
20+
To update the config file with new note types from Anki, run `obsidian_to_anki -u`
21+
22+
To add appropriately-formatted notes from a file, run `obsidian_to_anki -f {FILENAME}`
23+
24+
### Note formatting
1725

1826
In the markdown file, you must format your notes as follows:
1927

20-
START
21-
{Note Type}
22-
{Note Data}
23-
END
28+
> START
29+
> {Note Type}
30+
> {Note Data}
31+
> END
32+
33+
2434

2535
Apart from the first field, each field must have a prefix to indicate to the program when to move on to the next field. For example:
2636

27-
START
28-
Basic
29-
This is a test.
30-
Back: Test successful!
31-
END
37+
> START
38+
> Basic
39+
> This is a test.
40+
> Back: Test successful!
41+
> END
42+
43+
When the script successfully adds a note, it will append an ID to the Note Data. This allows you to update existing notes by running the script again.
44+
45+
Example output:
46+
47+
> START
48+
> Basic
49+
> This is a test.
50+
> Back: Test successful!
51+
> ID: 1566052191670
52+
> END
53+
54+
### Default
55+
By default, the script:
56+
- Adds notes with the tag "Obsidian_to_Anki"
57+
- Adds to the Default deck
58+
- Adds to the current profile in Anki
3259

60+
## Config
3361
The configuration file allows you to change two things:
3462
1. The substitutions for field prefixes. For example, under the section ['Basic'], you'll see something like this:
3563

36-
Front = Front:
37-
Back = Back:
64+
> Front = Front:
65+
> Back = Back:
3866
3967
If you edit and save this to say
4068

41-
Front = Front:
42-
Back = A:
69+
> Front = Front:
70+
> Back = A:
4371
4472
Then you now format your notes like this:
4573

46-
START
47-
Basic
48-
This is a test.
49-
A: Test successful!
50-
END
74+
> START
75+
> Basic
76+
> This is a test.
77+
> A: Test successful!
78+
> END
5179
5280

5381
2. The substitutions for notes. These are under the section ['Note Substitutions']. Similar to the above, you'll see something like this:
54-
...
55-
Basic = Basic
56-
Basic (and reversed) = Basic (and reversed)
57-
...
82+
> ...
83+
> Basic = Basic
84+
> Basic (and reversed) = Basic (and reversed)
85+
> ...
5886
5987
If you edit and save this to say
60-
...
61-
Basic = B
62-
Basic (and reversed) = Basic (and reversed)
63-
...
88+
> ...
89+
> Basic = B
90+
> Basic (and reversed) = Basic (and reversed)
91+
> ...
6492
6593
Then you now format your notes like this:
66-
START
67-
B
68-
{Note Data}
69-
END
94+
> START
95+
> B
96+
> {Note Data}
97+
> END
98+
99+
## Supported?
100+
101+
Currently supported features:
102+
* Custom note types
103+
* Updating notes from Obsidian
104+
* Substitutions (see above)
105+
* Auto-convert math formatting
106+
107+
Not currently supported features:
108+
* Media
109+
* Markdown formatting
110+
* Tags
111+
* Adding to decks other than Default

obsidian_to_anki.py

Lines changed: 99 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,26 @@
66
import configparser
77
import os
88
import argparse
9+
import collections
10+
import webbrowser
11+
12+
13+
def write_safe(filename, contents):
14+
"""
15+
Write contents to filename while keeping a backup.
16+
17+
If write fails, a backup 'filename.bak' will still exist.
18+
"""
19+
with open(filename + ".tmp", "w") as temp:
20+
temp.write(contents)
21+
os.rename(filename, filename + ".bak")
22+
os.rename(filename + ".tmp", filename)
23+
success = False
24+
with open(filename) as f:
25+
if f.read() == contents:
26+
success = True
27+
if success:
28+
os.remove(filename + ".bak")
929

1030

1131
class AnkiConnect:
@@ -32,6 +52,22 @@ def invoke(action, **params):
3252
raise Exception(response['error'])
3353
return response['result']
3454

55+
def add_or_update(note_and_id):
56+
"""Add the note if id is None, otherwise update the note."""
57+
note, identifier = note_and_id.note, note_and_id.id
58+
if identifier is None:
59+
return AnkiConnect.invoke(
60+
"addNote", note=note
61+
)
62+
else:
63+
update_note = dict()
64+
update_note["id"] = identifier
65+
update_note["fields"] = note["fields"]
66+
update_note["audio"] = note["audio"]
67+
return AnkiConnect.invoke(
68+
"updateNoteFields", note=update_note
69+
)
70+
3571

3672
class FormatConverter:
3773
"""Converting Obsidian formatting to Anki formatting."""
@@ -96,9 +132,12 @@ class Note:
96132
"allowDuplicate": False,
97133
"duplicateScope": "deck"
98134
},
99-
"tags": list(),
135+
"tags": ["Obsidian_to_Anki"],
136+
# ^So that you can see what was added automatically.
100137
"audio": list()
101138
}
139+
ID_PREFIX = "ID: "
140+
Note_and_id = collections.namedtuple('Note_and_id', ['note', 'id'])
102141

103142
def __init__(self, note_text):
104143
"""Set up useful variables."""
@@ -108,6 +147,11 @@ def __init__(self, note_text):
108147
self.subs = Note.field_subs[self.note_type]
109148
self.current_field_num = 0
110149
self.field_names = list(self.subs)
150+
if self.lines[-1].startswith(Note.ID_PREFIX):
151+
self.identifier = int(self.lines.pop()[len(Note.ID_PREFIX):])
152+
# The above removes the identifier line, for convenience of parsing
153+
else:
154+
self.identifier = None
111155

112156
@property
113157
def current_field(self):
@@ -151,9 +195,7 @@ def parse(self):
151195
template = Note.NOTE_DICT_TEMPLATE.copy()
152196
template["modelName"] = self.note_type
153197
template["fields"] = self.fields
154-
template["tags"] = ["Obsidian_to_Anki"]
155-
# ^So that you can see what was added automatically.
156-
return template
198+
return Note.Note_and_id(note=template, id=self.identifier)
157199

158200

159201
class Config:
@@ -217,44 +259,71 @@ class App:
217259
description="Add cards to Anki from an Obsidian markdown file."
218260
)
219261
parser.add_argument(
220-
"-filename", type=str, help="The file you want to add flashcards from."
262+
"-f",
263+
type=str,
264+
help="The file you want to add flashcards from.",
265+
dest="filename"
221266
)
222267
parser.add_argument(
223-
"-update",
268+
"-c", "--config",
224269
action="store_true",
270+
dest="config",
225271
help="""
226-
Whether you want to update the config file
227-
using new notes from Anki.
228-
Note that this does NOT open the config file for editing,
229-
you have to do that manually.
230-
""",
272+
Opens up config file for editing.
273+
"""
231274
)
232275
parser.add_argument(
233-
"-config",
276+
"-u", "--update",
234277
action="store_true",
278+
dest="update",
235279
help="""
236-
Opens up config file for editing.
237-
"""
280+
Whether you want to update the config file
281+
using new notes from Anki.
282+
Note that this does NOT open the config file for editing,
283+
use -c for that.
284+
""",
238285
)
239286

240287
NOTE_REGEXP = re.compile(r"(?<=START\n)[\s\S]*?(?=END\n?)")
241288

242-
def notes_from_file(filename):
243-
"""Get the notes from this file."""
289+
def anki_from_file(filename):
290+
"""Add to or update notes from Anki, from filename."""
291+
print("Adding notes from", filename, "...")
244292
with open(filename) as f:
245293
file = f.read()
246-
return App.NOTE_REGEXP.findall(file)
247-
248-
def anki_from_file(filename):
249-
"""Add notes to anki from this file."""
250-
print("Adding notes to Anki...")
251-
result = AnkiConnect.invoke(
252-
"addNotes",
253-
notes=[
254-
Note(note).parse() for note in App.notes_from_file(filename)
255-
]
256-
)
257-
return result
294+
updated_file = file
295+
position = 0
296+
match = App.NOTE_REGEXP.search(updated_file, position)
297+
while match:
298+
note = match.group(0)
299+
parsed = Note(note).parse()
300+
result = AnkiConnect.add_or_update(parsed)
301+
position = match.end()
302+
if result is not None and parsed.id is None:
303+
# This indicates a new note was added successfully:
304+
305+
# Result being None means either error or the result is
306+
# an identifier.
307+
308+
# parsed.id being None means that there was
309+
# No ID to begin with.
310+
311+
# So, we need to insert the note ID as a line.
312+
print(
313+
"Successfully added note with ID",
314+
result
315+
)
316+
updated_file = "".join([
317+
updated_file[:match.end()],
318+
Note.ID_PREFIX + str(result) + "\n",
319+
updated_file[match.end():]
320+
])
321+
position += len(Note.ID_PREFIX + str(result) + "\n")
322+
else:
323+
print("Successfully updated note with ID", parsed.id)
324+
match = App.NOTE_REGEXP.search(updated_file, position)
325+
print("All notes from", filename, "added, now writing new IDs.")
326+
write_safe(filename, updated_file)
258327

259328
def main():
260329
"""Execute the main functionality of the script."""
@@ -263,10 +332,10 @@ def main():
263332
Config.update_config()
264333
Config.load_config()
265334
if args.config:
266-
os.startfile(Config.CONFIG_PATH)
335+
webbrowser.open(Config.CONFIG_PATH)
267336
return
268337
if args.filename:
269-
print("Success! IDs are", App.anki_from_file(args.filename))
338+
App.anki_from_file(args.filename)
270339

271340

272341
if __name__ == "__main__":

0 commit comments

Comments
 (0)