Skip to content

Commit 3fc412d

Browse files
committed
Add initial Anki deck generation from Obsidian vault
1 parent 19cbd2f commit 3fc412d

8 files changed

+191
-66
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ This is my command-line tool for markdown-based knowledge bases called `obsi`.
33
I use with my markdown-based [Obsidian](https://obsidian.md) notes.
44

55
## Features
6+
- generation of Anki Decks from your Obsidian Vault
67
- index generation for tags: create pages that list all usages of a specific tag
78
- tag recommendations (based on other tags) with machine learning
89
- generation of calendar-related notes: daily, weekly, and monthly notes with respective links

cli.py

+20-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
"""
2+
obsi command line interface.
3+
"""
4+
15
import logging
26
from collections import defaultdict
37
from datetime import datetime, timedelta
48
from pathlib import Path
59

610
import click
711

12+
from obsi.anki import generate_anki_deck
813
from obsi.markdown import create_day, create_index, create_note_list, create_week
914
from obsi.ml import generate_tag_recommendations
10-
from obsi.storage import day_date_to_path, day_date_to_week_path, gen_notes
15+
from obsi.storage import Vault, day_date_to_path, day_date_to_week_path
1116

1217
DAY_GENERATION_PADDING = 100
1318
WEEK_GENERATION_PADDING = 52
@@ -28,6 +33,14 @@ def run():
2833
update_indexes()
2934

3035

36+
@cli.command()
37+
def anki_deck():
38+
name = f"Obsi notes for {NOTES_PATH}"
39+
vault = Vault(NOTES_PATH)
40+
notes = list(vault.generate_notes())
41+
generate_anki_deck(name, notes, out_file="out/deck.apkg")
42+
43+
3144
def update_days(padding=DAY_GENERATION_PADDING):
3245
"""
3346
Generates all days for the given padding.
@@ -65,7 +78,7 @@ def update_weeks(padding=WEEK_GENERATION_PADDING):
6578

6679

6780
def update_recommendations():
68-
notes = list(gen_notes(NOTES_PATH))
81+
notes = list(get_vault().generate_notes())
6982
for tag, notes_rec in generate_tag_recommendations(notes):
7083
content = create_note_list(tag, notes_rec)
7184
with open("out/recommendations-" + tag.lower().replace("#", ""), "w") as file:
@@ -84,7 +97,7 @@ def update_indexes():
8497
def generate_indexes(untagged_index=True):
8598
notes_per_tag = defaultdict(set)
8699
notes_untagged = []
87-
for note in gen_notes(NOTES_PATH):
100+
for note in get_vault().generate_notes():
88101
for tag in note.tags:
89102
notes_per_tag[tag].add(note)
90103

@@ -103,6 +116,10 @@ def generate_indexes(untagged_index=True):
103116
yield "index-untagged", create_index("untagged notes", notes_untagged)
104117

105118

119+
def get_vault():
120+
return Vault(NOTES_PATH)
121+
122+
106123
if __name__ == "__main__":
107124
logging.basicConfig(level=logging.INFO)
108125
cli()

obsi/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
1+
"""
2+
obsi package.
3+
"""
4+
15
DAY_FORMAT = "%Y-%m-%d"
26
MONTH_FORMAT = "%Y-%m"

obsi/anki.py

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""
2+
module to generate Anki decks from notes.
3+
"""
4+
import html
5+
import logging
6+
7+
import genanki
8+
9+
10+
class FileBasedNote(genanki.Note):
11+
"""
12+
Anki note based on a Vault file.
13+
"""
14+
15+
def __init__(self, path, *args, **kwargs):
16+
super().__init__(*args, **kwargs)
17+
18+
# use the path as the unique identifier
19+
# ensures that cards that keep their path get updated in place
20+
self._guid = genanki.guid_for(path)
21+
22+
23+
def generate_anki_deck(name, notes, out_file="output.apkg"):
24+
"""
25+
Generate an anki deck .apkg file from the given notes.
26+
27+
:param name: name of the deck
28+
:param notes: notes to use for generation
29+
:param out_file: path of resulting file
30+
"""
31+
# generate the deck
32+
anki_deck = genanki.Deck(2059400110, html.escape(name))
33+
34+
# set a model
35+
anki_model = genanki.Model(
36+
1607392319,
37+
"File-based Card",
38+
fields=[
39+
{"name": "Path"},
40+
{"name": "ObsidianUri"},
41+
],
42+
templates=[
43+
{
44+
"name": "Card 1",
45+
"qfmt": "What do you know about {{Path}}",
46+
"afmt": '{{FrontSide}}<hr id="answer"><a href="{{ObsidianUri}}">open note</a>',
47+
}
48+
],
49+
)
50+
51+
for note in notes:
52+
anki_node = FileBasedNote(
53+
path=note,
54+
model=anki_model,
55+
fields=[
56+
html.escape(str(note.get_relative_path())),
57+
html.escape(note.get_obsidian_uri()),
58+
],
59+
)
60+
anki_deck.add_note(anki_node)
61+
62+
genanki.Package(anki_deck).write_to_file(out_file)
63+
logging.info(f".apkg written to {out_file=}")

obsi/storage.py

+43-21
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,71 @@
11
import re
2+
import typing
23
from pathlib import Path
4+
from urllib.parse import urlencode
35

46
from obsi import DAY_FORMAT
57
from obsi.util import week_identifier_from_date
68

79

10+
class Vault:
11+
"""
12+
An obsidian vault.
13+
"""
14+
path = None
15+
16+
def __init__(self, path: typing.Union[Path, str]):
17+
self.path = path if type(path) == Path else Path(path)
18+
assert self.path.is_dir(), f"{self.path} is not a dir"
19+
20+
def generate_notes(self):
21+
"""Generate all notes in vault"""
22+
for file_path in self.path.rglob("*.md"):
23+
rel_path = file_path.relative_to(self.path)
24+
yield Note.from_path(self, rel_path)
25+
26+
def __repr__(self):
27+
return f"Vault({self.path=})"
28+
29+
830
class Note:
31+
"""
32+
A markdown-based note.
33+
"""
934
@classmethod
10-
def from_path(cls, path):
11-
content = path_to_content(path)
12-
return Note(path, content)
13-
14-
def __init__(self, path, content):
35+
def from_path(cls, vault: Vault, path: Path):
36+
path_abs = vault.path.joinpath(path)
37+
assert path_abs.is_file(), f"no file found at {path_abs}"
38+
content = path_to_content(path_abs)
39+
return Note(vault, path, content)
40+
41+
def __init__(self, vault: Vault, path: Path, content: str):
42+
self._vault = vault
1543
self._path = path
16-
self.content = content
44+
self._content = content
1745

1846
def get_tags(self):
19-
results = re.findall(r"#[A-z0-9]+", self.content)
47+
results = re.findall(r"#[A-z0-9]+", self._content)
2048
return set(results)
2149

22-
def get_path(self):
50+
def get_absolute_path(self):
51+
return self._vault.path.joinpath(self._path)
52+
53+
def get_relative_path(self):
2354
return self._path
2455

56+
def get_obsidian_uri(self):
57+
return "obsidian://open?" + urlencode({"vault": "notes", "file": self._path})
58+
2559
def get_title(self):
2660
# todo md title
2761
# todo frontmatter title
2862
return self._path.name.replace(".md", "")
2963

3064
tags = property(get_tags)
31-
path = property(get_path)
3265
title = property(get_title)
3366

3467
def __repr__(self):
35-
return f"Note({self.path=})"
36-
37-
38-
def gen_notes(path):
39-
"""Generate all notes in a directory."""
40-
home_path = Path(path)
41-
42-
# check that path exists
43-
assert home_path.is_dir()
44-
45-
for file_path in home_path.rglob("*.md"):
46-
yield Note.from_path(file_path)
68+
return f"Note({self._vault=}, {self._path=})"
4769

4870

4971
def path_to_content(path):

requirements.in

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ pytest-cov
1111
click
1212
jinja2
1313
pandas
14-
scikit-learn
14+
scikit-learn
15+
genanki

0 commit comments

Comments
 (0)