Skip to content

Commit

Permalink
Merge pull request #2 from Pseudonium/develop
Browse files Browse the repository at this point in the history
Develop - Note Update
  • Loading branch information
Pseudonium authored Sep 1, 2020
2 parents 7865304 + f00415f commit baa7591
Show file tree
Hide file tree
Showing 2 changed files with 179 additions and 68 deletions.
118 changes: 80 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,68 +2,110 @@
Script to add flashcards from an Obsidian markdown file to Anki.

## Setup
Download the script from the repository. You may wish to consider placing it in a Scripts folder, and adding the script to your PATH.
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).

Once you've placed the script in the desired directory, run it once with no arguments:
`obsidian_to_anki.py`

This will make a configuration file, `obsidian_to_anki_config.ini`.
1. Install [Python](https://www.python.org/downloads/)
2. Download the desired release.
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
4. Start up Anki, and navigate to your desired profile
5. Ensure that you've installed [AnkiConnect](https://github.com/FooSoft/anki-connect).
6. From the command line, run the script once with no arguments - `{Path to script}/obsidian_to_anki.py`
This will make a configuration file in the same directory as the script, "obsidian_to_anki_config.ini".

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

Note that you need to have Anki running when using the script.
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)

**All other operations of the script require Anki to be running.**

To update the config file with new note types from Anki, run `obsidian_to_anki -u`

To add appropriately-formatted notes from a file, run `obsidian_to_anki -f {FILENAME}`

### Note formatting

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

START
{Note Type}
{Note Data}
END
> START
> {Note Type}
> {Note Data}
> END


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:

START
Basic
This is a test.
Back: Test successful!
END
> START
> Basic
> This is a test.
> Back: Test successful!
> END
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.

Example output:

> START
> Basic
> This is a test.
> Back: Test successful!
> ID: 1566052191670
> END
### Default
By default, the script:
- Adds notes with the tag "Obsidian_to_Anki"
- Adds to the Default deck
- Adds to the current profile in Anki

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

Front = Front:
Back = Back:
> Front = Front:
> Back = Back:
If you edit and save this to say

Front = Front:
Back = A:
> Front = Front:
> Back = A:
Then you now format your notes like this:

START
Basic
This is a test.
A: Test successful!
END
> START
> Basic
> This is a test.
> A: Test successful!
> END

2. The substitutions for notes. These are under the section ['Note Substitutions']. Similar to the above, you'll see something like this:
...
Basic = Basic
Basic (and reversed) = Basic (and reversed)
...
> ...
> Basic = Basic
> Basic (and reversed) = Basic (and reversed)
> ...
If you edit and save this to say
...
Basic = B
Basic (and reversed) = Basic (and reversed)
...
> ...
> Basic = B
> Basic (and reversed) = Basic (and reversed)
> ...
Then you now format your notes like this:
START
B
{Note Data}
END
> START
> B
> {Note Data}
> END
## Supported?

Currently supported features:
* Custom note types
* Updating notes from Obsidian
* Substitutions (see above)
* Auto-convert math formatting

Not currently supported features:
* Media
* Markdown formatting
* Tags
* Adding to decks other than Default
129 changes: 99 additions & 30 deletions obsidian_to_anki.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@
import configparser
import os
import argparse
import collections
import webbrowser


def write_safe(filename, contents):
"""
Write contents to filename while keeping a backup.
If write fails, a backup 'filename.bak' will still exist.
"""
with open(filename + ".tmp", "w") as temp:
temp.write(contents)
os.rename(filename, filename + ".bak")
os.rename(filename + ".tmp", filename)
success = False
with open(filename) as f:
if f.read() == contents:
success = True
if success:
os.remove(filename + ".bak")


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

def add_or_update(note_and_id):
"""Add the note if id is None, otherwise update the note."""
note, identifier = note_and_id.note, note_and_id.id
if identifier is None:
return AnkiConnect.invoke(
"addNote", note=note
)
else:
update_note = dict()
update_note["id"] = identifier
update_note["fields"] = note["fields"]
update_note["audio"] = note["audio"]
return AnkiConnect.invoke(
"updateNoteFields", note=update_note
)


class FormatConverter:
"""Converting Obsidian formatting to Anki formatting."""
Expand Down Expand Up @@ -96,9 +132,12 @@ class Note:
"allowDuplicate": False,
"duplicateScope": "deck"
},
"tags": list(),
"tags": ["Obsidian_to_Anki"],
# ^So that you can see what was added automatically.
"audio": list()
}
ID_PREFIX = "ID: "
Note_and_id = collections.namedtuple('Note_and_id', ['note', 'id'])

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

@property
def current_field(self):
Expand Down Expand Up @@ -151,9 +195,7 @@ def parse(self):
template = Note.NOTE_DICT_TEMPLATE.copy()
template["modelName"] = self.note_type
template["fields"] = self.fields
template["tags"] = ["Obsidian_to_Anki"]
# ^So that you can see what was added automatically.
return template
return Note.Note_and_id(note=template, id=self.identifier)


class Config:
Expand Down Expand Up @@ -217,44 +259,71 @@ class App:
description="Add cards to Anki from an Obsidian markdown file."
)
parser.add_argument(
"-filename", type=str, help="The file you want to add flashcards from."
"-f",
type=str,
help="The file you want to add flashcards from.",
dest="filename"
)
parser.add_argument(
"-update",
"-c", "--config",
action="store_true",
dest="config",
help="""
Whether you want to update the config file
using new notes from Anki.
Note that this does NOT open the config file for editing,
you have to do that manually.
""",
Opens up config file for editing.
"""
)
parser.add_argument(
"-config",
"-u", "--update",
action="store_true",
dest="update",
help="""
Opens up config file for editing.
"""
Whether you want to update the config file
using new notes from Anki.
Note that this does NOT open the config file for editing,
use -c for that.
""",
)

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

def notes_from_file(filename):
"""Get the notes from this file."""
def anki_from_file(filename):
"""Add to or update notes from Anki, from filename."""
print("Adding notes from", filename, "...")
with open(filename) as f:
file = f.read()
return App.NOTE_REGEXP.findall(file)

def anki_from_file(filename):
"""Add notes to anki from this file."""
print("Adding notes to Anki...")
result = AnkiConnect.invoke(
"addNotes",
notes=[
Note(note).parse() for note in App.notes_from_file(filename)
]
)
return result
updated_file = file
position = 0
match = App.NOTE_REGEXP.search(updated_file, position)
while match:
note = match.group(0)
parsed = Note(note).parse()
result = AnkiConnect.add_or_update(parsed)
position = match.end()
if result is not None and parsed.id is None:
# This indicates a new note was added successfully:

# Result being None means either error or the result is
# an identifier.

# parsed.id being None means that there was
# No ID to begin with.

# So, we need to insert the note ID as a line.
print(
"Successfully added note with ID",
result
)
updated_file = "".join([
updated_file[:match.end()],
Note.ID_PREFIX + str(result) + "\n",
updated_file[match.end():]
])
position += len(Note.ID_PREFIX + str(result) + "\n")
else:
print("Successfully updated note with ID", parsed.id)
match = App.NOTE_REGEXP.search(updated_file, position)
print("All notes from", filename, "added, now writing new IDs.")
write_safe(filename, updated_file)

def main():
"""Execute the main functionality of the script."""
Expand All @@ -263,10 +332,10 @@ def main():
Config.update_config()
Config.load_config()
if args.config:
os.startfile(Config.CONFIG_PATH)
webbrowser.open(Config.CONFIG_PATH)
return
if args.filename:
print("Success! IDs are", App.anki_from_file(args.filename))
App.anki_from_file(args.filename)


if __name__ == "__main__":
Expand Down

0 comments on commit baa7591

Please sign in to comment.