added support for task lists

This commit is contained in:
Amy G. Bowersox 2024-08-10 13:33:55 -06:00
parent 7bbb53fc13
commit 818647fbdf
2 changed files with 86 additions and 26 deletions

View File

@ -382,6 +382,12 @@ class ObsidianLinks(Extension):
return el, m.start(0), m.end(0)
def extendMarkdown(self, md: markdown.Markdown) -> None: # noqa: N802
"""
Registers the link inline processors with the Markdown parser.
Args:
md (markdown.Markdown): The Markdown parser to register the patterns with.
"""
md.inlinePatterns.register(ObsidianLinks.ObsidianLinksProc(OBSLINK_PATTERN, md, self),
'obsidian_links', PRIO_BASE + 110)
md.inlinePatterns.register(ObsidianLinks.GenericLinksProc(GENERICLINK_PATTERN, md, self),
@ -389,9 +395,10 @@ class ObsidianLinks(Extension):
class ObsidianLists(Extension):
"""An extension to process Obsidian lists, including making nested lists."""
"""An extension to process Obsidian lists, including making nested lists and task lists."""
UL_RE = re.compile(r'^([ ]*)[-+*][ ]+(.*)')
OL_RE = re.compile(r'^([ ]*)([1-9][0-9]*)\.[ ]+(.*)')
TASK_RE = re.compile(r'^([ ]*)-[ ]\[(.)\][ ]+(.*)')
LIST_START = STX + "erbosoft:lstart" + ETX
LIST_END = STX + "erbosoft:lend" + ETX
@ -419,11 +426,27 @@ class ObsidianLists(Extension):
return '', -1, -1
class ObsidianListFinder(Preprocessor):
"""Preprocessor that finds and marks blocks in the text that contain lists."""
def __init__(self, extref: Any) -> None:
"""
Initialize the ObsidianListFinder.
Args:
extref (ObsidianLists): Reference to the outer object.
"""
super(ObsidianLists.ObsidianListFinder, self).__init__()
self._extref = extref
def run(self, lines: list[str]) -> list[str]:
"""
Execute the preprocessor on the Markdown text.
Args:
lines (list[str]): List of lines of text in the input document.
Returns:
list[str]: The list of lines to carry forward with.
"""
i = 0
in_list = False
while i < len(lines):
@ -444,34 +467,43 @@ class ObsidianLists(Extension):
class ObsidianListBlock(BlockProcessor):
"""The actual block processor that generates lists."""
def __init__(self, parser: BlockParser, extref: Any) -> None:
"""
Initialize the ObsidianListBlock.
Args:
parser (BlockParser): Reference to the block parser.
extref (ObsidianLists): Backreference to the outer object.
"""
super(ObsidianLists.ObsidianListBlock, self).__init__(parser)
self._extref = extref
@staticmethod
def _extract(blocks: list[str]) -> list[str]:
def _attach_checkbox(listitem: etree.Element, data_line: int, cbox: str, text: str) -> None:
"""
Extract all lines up to the next blank line, which terminates the list.
Attach a checkbox and its associated text to the ``li`` element.
Args:
blocks (list[str]): List of blocks to be parsed.
Returns:
list[str]: The block of lines to be parsed for a list.
listitem (etree.Element): The ``li`` element that's being worked on.
data_line (int): Data line index for this list item.
cbox (str): The checkbox marker character.
text (str): The text to include after the checkbox.
"""
out_lines: list[str] = []
while len(blocks) > 0:
curblk = blocks.pop(0)
cur_lines = curblk.split('\n')
while len(cur_lines) > 0:
line = cur_lines.pop(0)
if line.strip() == '':
cur_lines.insert(0, line)
blocks.insert(0, '\n'.join(cur_lines))
return out_lines
out_lines.append(line)
return out_lines
checkbox = etree.SubElement(listitem, 'input')
checkbox.attrib['type'] = 'checkbox'
checkbox.attrib['class'] = 'task-list-item-checkbox'
checkbox.attrib['aria-label'] = 'Task'
checkbox.attrib['data-line'] = str(data_line)
checkbox.attrib['data-task'] = cbox
if not cbox.isspace():
checkbox.attrib['checked'] = 'checked'
if cbox == 'x':
span = etree.SubElement(listitem, 'span')
span.attrib['class'] = 'task-list-item-checked'
span.text = text
else:
checkbox.tail = text
def _build_element(self, parent: etree.Element, listtype: str, indent: int, lines: list[str]) -> None:
def _build_element(self, parent: etree.Element, listtype: str, indent: int, data_line: int, lines: list[str]) -> None:
"""
Builds a list element and adds it to the specified parent list.
@ -479,9 +511,11 @@ class ObsidianLists(Extension):
parent (etree.element): The parent list element to insert "li" items under.
listtype (str): The list type, either "ol" or "ul".
indent (int): The indent level of the list, which is greater than or equal to 0.
data_line (int): The data line index of the element.
lines (list[str]): The lines comprising the list element.
"""
textdata: list[str] = []
cbox: str | None = None
# Parse the header line to get the first bit of text.
if listtype == 'ol':
@ -489,12 +523,24 @@ class ObsidianLists(Extension):
assert len(m.group(1)) == indent
textdata.append(m.group(3))
elif listtype == 'ul':
m = self._extref.UL_RE.match(lines[0])
assert len(m.group(1)) == indent
textdata.append(m.group(2))
m = self._extref.TASK_RE.match(lines[0])
if m:
assert len(m.group(1)) == indent
cbox = m.group(2)
textdata.append(m.group(3))
else:
m = self._extref.UL_RE.match(lines[0])
assert len(m.group(1)) == indent
textdata.append(m.group(2))
# Build the list element
listelement = etree.SubElement(parent, 'li')
listelement.attrib['data-line'] = str(data_line)
if cbox is not None:
classname = 'task-list-item'
if not cbox.isspace():
classname = f"{classname} is-checked"
listelement.attrib['class'] = classname
my_lines = list(lines)
i = 1
last_sublist: etree.Element | None = None
@ -510,6 +556,8 @@ class ObsidianLists(Extension):
# flush text data
if last_sublist is not None:
last_sublist.tail = '\n'.join(textdata)
elif cbox is not None:
self._attach_checkbox(listelement, data_line, cbox, '\n'.join(textdata))
else:
listelement.text = '\n'.join(textdata)
textdata = []
@ -521,6 +569,8 @@ class ObsidianLists(Extension):
if len(textdata) > 0:
if last_sublist is not None:
last_sublist.tail = '\n'.join(textdata)
elif cbox is not None:
self._attach_checkbox(listelement, data_line, cbox, '\n'.join(textdata))
else:
listelement.text = '\n'.join(textdata)
@ -543,27 +593,34 @@ class ObsidianLists(Extension):
list_top = etree.SubElement(parent, listtype)
if listtype == 'ol' and start_index > 1:
list_top.attrib['start'] = str(start_index)
if listtype == 'ul':
m = self._extref.TASK_RE.match(lines[0])
if m:
list_top.attrib['class'] = 'contains-task-list'
# The start index is set to -1 so, the first time around, we'll pick it up and set the start point.
st = -1
data_line = 0
for i, line in enumerate(lines):
new_type, new_indent, _ = self._extref._find_listhead(lines[i])
if len(new_type) > 0:
if new_indent < indent_level:
# if the list head is lesser indented, this is the end of this element and the list
if st >= 0:
self._build_element(list_top, listtype, indent_level, lines[st:i])
self._build_element(list_top, listtype, indent_level, data_line, lines[st:i])
data_line += 1
return list_top, lines[i:]
if new_indent == indent_level:
# this is the end of this element
if st >= 0:
self._build_element(list_top, listtype, indent_level, lines[st:i])
self._build_element(list_top, listtype, indent_level, data_line, lines[st:i])
data_line += 1
if new_type != listtype:
# this is also the end of the list
return list_top, lines[i:]
st = i # start of next element
if st >= 0: # end the final element
self._build_element(list_top, listtype, indent_level, lines[st:])
self._build_element(list_top, listtype, indent_level, data_line, lines[st:])
return list_top, []
def test(self, parent: etree.Element, block: str) -> bool:

View File

@ -28,6 +28,9 @@ blockquote {
margin-right: 1em;
border-left: 2px solid #9873f7;
}
span.task-list-item-checked {
text-decoration-line: line-through;
}
"""