import dataclasses import re import frontmatter import marko import marko.md_renderer ################################################################################ # FrontMatter keys FM_KEY_TODO_LIST_COMPLETED = 'todo-list-completed' FM_KEY_TAGS = 'tags' FM_KEY_ASSIGNMENTS = 'assignments' FM_KEY_DEPENDENCIES = 'dependencies' FM_KEY_URL = 'url' FM_KEY_ALIASES = 'aliases' ################################################################################ @dataclasses.dataclass(frozen=True) class CardContents: identifier: str | None name: str | None description: str | None tags: list[str] assignments: list[str] card_dependencies: list[str] url: str todo_list_completed: bool | None def format_obsidian_link(text: str) -> str: return f'[[{text}]]' def parse_obsidian_link(text: str) -> str: if m := re.match(r'^\[\[(.*)\]\]$', text): return m.group(1) return text class CardFileFormatter: """Component for formatting and parsing card files.""" def __init__(self, obsidian_mode=True): """Initialize card formatter. Arguments: - `obsidian_mode`: Configure formatter to enable maximum compatibility with [Obsidian](https://obsidian.md/). Enables internal links, and exposes certain aliases. """ self.obsidian_mode = obsidian_mode self.markdown = marko.Markdown() self.renderer = marko.md_renderer.MarkdownRenderer() def format_card_contents(self, card: CardContents) -> str: """Formats card contents. Mostly the inverse of [`parse_card_contents`].""" # Choose frontmatter data frontmatter_data = {} if self.obsidian_mode: aliases = [] if card.name: aliases.append(card.name) if card.identifier and card.name: aliases.append(f'{card.identifier}: {card.name}') if aliases: frontmatter_data[FM_KEY_ALIASES] = aliases del aliases if card.tags: frontmatter_data[FM_KEY_TAGS] = card.tags if card.url and self.obsidian_mode: frontmatter_data[FM_KEY_URL] = card.url if card.assignments: frontmatter_data[FM_KEY_ASSIGNMENTS] = card.assignments if self.obsidian_mode: frontmatter_data[FM_KEY_ASSIGNMENTS] = [ format_obsidian_link(name) for name in frontmatter_data[FM_KEY_ASSIGNMENTS] ] if card.card_dependencies: frontmatter_data[FM_KEY_DEPENDENCIES] = card.card_dependencies if self.obsidian_mode: frontmatter_data[FM_KEY_DEPENDENCIES] = [ format_obsidian_link(name) for name in frontmatter_data[FM_KEY_DEPENDENCIES] ] if card.todo_list_completed: frontmatter_data[FM_KEY_TODO_LIST_COMPLETED] = card.todo_list_completed # Card name ls = [] if card.name: ls.append('# ') ls.append(card.name) ls.append('\n\n') # Card contents if description := card.description: if self.obsidian_mode: description = re.sub(r'\-\s*\[\s*\]', '- [ ]', description, flags=re.MULTILINE) ls.append(description) del description fm = frontmatter.Post(''.join(ls), **frontmatter_data) return frontmatter.dumps(fm) def parse_card_contents(self, contents: str) -> CardContents: """Parses card contents. Mostly the inverse of [`parse_card_contents`]. 1. Strips frontmatter and parses certain fields from the header. 2. Parses header 3. Finds content. """ fm = frontmatter.loads(contents) del contents document = self.markdown.parse(fm.content.strip()) name = None for elem in document.children: if isinstance(elem, marko.block.Heading): name = self.renderer.render_children(elem) document.children.remove(elem) break tags: list[str] = fm.metadata.get(FM_KEY_TAGS, []) assignments: list[str] = fm.metadata.get(FM_KEY_ASSIGNMENTS, []) assignments = [parse_obsidian_link(text) for text in assignments] card_dependencies: list[str] = fm.metadata.get(FM_KEY_DEPENDENCIES, []) card_dependencies = [parse_obsidian_link(text) for text in card_dependencies] url: list[str] = fm.metadata.get(FM_KEY_URL) todo_list_completed: bool | None = fm.metadata.get(FM_KEY_TODO_LIST_COMPLETED) description = self.renderer.render_children(document).strip() return CardContents( None, name, description, tags=tags, assignments=assignments, card_dependencies=card_dependencies, url=url, todo_list_completed=todo_list_completed, )