From 0ac15039f774c09dfa11396206be769ffaa0e205 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sun, 22 Feb 2026 20:44:41 +0100 Subject: [PATCH 01/13] Allow editing headline components dynamically. --- src/orgparse/node.py | 173 +++++++++++++++++++++++++++++--- src/orgparse/tests/test_misc.py | 40 +++++++- 2 files changed, 196 insertions(+), 17 deletions(-) diff --git a/src/orgparse/node.py b/src/orgparse/node.py index 5794b43..4d56913 100644 --- a/src/orgparse/node.py +++ b/src/orgparse/node.py @@ -33,7 +33,7 @@ def lines_to_chunks(lines: Iterable[str]) -> Iterable[list[str]]: yield chunk -RE_NODE_HEADER = re.compile(r"^\*+ ") +RE_NODE_HEADER = re.compile(r"^\s*\*+ ") def parse_heading_level(heading: str) -> tuple[str, int] | None: @@ -55,7 +55,7 @@ def parse_heading_level(heading: str) -> tuple[str, int] | None: return None -RE_HEADING_STARS = re.compile(r'^(\*+)\s+(.*?)\s*$') +RE_HEADING_STARS = re.compile(r'^\s*(\*+)\s+(.*?)\s*$') def parse_heading_tags(heading: str) -> tuple[str, list[str]]: @@ -113,7 +113,7 @@ def parse_heading_todos(heading: str, todo_candidates: list[str]) -> tuple[str, return (heading, None) -def parse_heading_priority(heading): +def parse_heading_priority(heading: str) -> tuple[str, Optional[str]]: """ Get priority and heading without priority field. @@ -139,6 +139,90 @@ def parse_heading_priority(heading): PropertyValue = Union[str, int, float] +class LineItem: + def render(self) -> str: + raise NotImplementedError + + +class TextLine(LineItem): + def __init__(self, raw: str) -> None: + self._raw = raw + + def render(self) -> str: + return self._raw + + +class HeadingLine(LineItem): + def __init__( + self, + raw: str, + level: int, + todo: Optional[str], + priority: Optional[str], + heading: str, + tags: Sequence[str], + ) -> None: + self._raw = raw + self.level = level + self.todo = todo + self.priority = priority + self.heading = heading + self.tags = list(tags) + self._dirty = False + + @classmethod + def from_line(cls, line: str, todo_candidates: list[str]) -> HeadingLine: + heading_level = parse_heading_level(line) + if heading_level is None: + raise ValueError(f"Invalid heading line: {line!r}") + (heading, level) = heading_level + (heading, tags) = parse_heading_tags(heading) + (heading, todo) = parse_heading_todos(heading, todo_candidates) + (heading, priority) = parse_heading_priority(heading) + return cls( + raw=line, + level=level, + todo=todo, + priority=priority, + heading=heading, + tags=tags, + ) + + def mark_dirty(self) -> None: + self._dirty = True + + def render(self) -> str: + if not self._dirty: + return self._raw + + stars = "*" * self.level + tokens: list[str] = [] + if self.todo: + tokens.append(self.todo) + if self.priority: + tokens.append(f"[#{self.priority}]") + if self.heading: + tokens.append(self.heading) + + if not tokens and not self.tags: + rendered = f"{stars} " + self._raw = rendered + self._dirty = False + return rendered + + rendered = f"{stars} " + if tokens: + rendered += " ".join(tokens) + if self.tags: + if tokens: + rendered += " " + rendered += ":" + ":".join(self.tags) + ":" + + self._raw = rendered + self._dirty = False + return rendered + + def parse_property(line: str) -> tuple[Optional[str], Optional[PropertyValue]]: """ Get property from given string. @@ -515,7 +599,9 @@ def __init__(self, env: OrgEnv, index: int | None = None) -> None: self.linenumber = cast(int, None) # set in parse_lines # content + self._line_items: list[LineItem] = [] self._lines: list[str] = [] + self._lines_dirty = False self._properties: dict[str, PropertyValue] = {} self._timestamps: list[OrgDate] = [] @@ -812,7 +898,8 @@ def get_property(self, key, val=None) -> Optional[PropertyValue]: @classmethod def from_chunk(cls, env, lines): self = cls(env) - self._lines = lines + self._lines = list(lines) + self._line_items = [TextLine(line) for line in self._lines] self._parse_comments() return self @@ -1055,7 +1142,20 @@ def rangelist(self): return self.get_timestamps(active=True, inactive=True, range=True) def __str__(self) -> str: - return "\n".join(self._lines) + return "\n".join(self._render_lines()) + + def _render_lines(self) -> list[str]: + if self._lines_dirty: + self._lines = [line.render() for line in self._line_items] + self._lines_dirty = False + return self._lines + + def _update_line_item(self, index: int, item: LineItem) -> None: + self._line_items[index] = item + if self._lines: + self._lines[index] = item.render() + else: + self._lines_dirty = True # todo hmm, not sure if it really belongs here and not to OrgRootNode? def get_file_property_list(self, property: str): # noqa: A002 @@ -1136,6 +1236,7 @@ def __init__(self, *args, **kwds) -> None: self._tags = cast(list[str], None) self._todo: Optional[str] = None self._priority = None + self._heading_line = cast(HeadingLine, None) self._scheduled = OrgDateScheduled(None) self._deadline = OrgDateDeadline(None) self._closed = OrgDateClosed(None) @@ -1162,14 +1263,32 @@ def _parse_pre(self): self._body_lines = list(ilines) def _parse_heading(self) -> None: - heading = self._lines[0] - heading_level = parse_heading_level(heading) - if heading_level is not None: - (heading, self._level) = heading_level - (heading, self._tags) = parse_heading_tags(heading) - (heading, self._todo) = parse_heading_todos(heading, self.env.all_todo_keys) - (heading, self._priority) = parse_heading_priority(heading) - self._heading = heading + self._heading_line = HeadingLine.from_line(self._lines[0], self.env.all_todo_keys) + self._level = self._heading_line.level + self._tags = list(self._heading_line.tags) + self._todo = self._heading_line.todo + self._priority = self._heading_line.priority + self._heading = self._heading_line.heading + self._update_line_item(0, self._heading_line) + + def _normalize_tags(self, tags: Iterable[str] | None) -> list[str]: + if tags is None: + return [] + if isinstance(tags, str): + return [tags] + if isinstance(tags, set): + return sorted(tags) + return list(tags) + + def _update_heading_line(self) -> None: + if not self._heading_line: + return + self._heading_line.todo = self._todo + self._heading_line.priority = self._priority + self._heading_line.heading = self._heading + self._heading_line.tags = list(self._tags) + self._heading_line.mark_dirty() + self._update_line_item(0, self._heading_line) # The following ``_iparse_*`` methods are simple generator based # parser. See ``_parse_pre`` for how it is used. The principle @@ -1262,6 +1381,11 @@ def heading(self) -> str: """Alias of ``.get_heading(format='plain')``.""" return self.get_heading() + @heading.setter + def heading(self, value: str) -> None: + self._heading = value + self._update_heading_line() + @property def level(self): """ @@ -1301,6 +1425,13 @@ def priority(self) -> str | None: """ return self._priority + @priority.setter + def priority(self, value: str | None) -> None: + if value == "": + value = None + self._priority = value + self._update_heading_line() + def _get_tags(self, *, inher: bool = False) -> set[str]: tags = set(self._tags) if inher: @@ -1322,6 +1453,22 @@ def todo(self) -> Optional[str]: """ return self._todo + @todo.setter + def todo(self, value: Optional[str]) -> None: + if value == "": + value = None + self._todo = value + self._update_heading_line() + + @property + def tags(self) -> set[str]: + return self._get_tags(inher=True) + + @tags.setter + def tags(self, value: Iterable[str] | None) -> None: + self._tags = self._normalize_tags(value) + self._update_heading_line() + @property def scheduled(self): """ diff --git a/src/orgparse/tests/test_misc.py b/src/orgparse/tests/test_misc.py index bb1382e..7b1c42c 100644 --- a/src/orgparse/tests/test_misc.py +++ b/src/orgparse/tests/test_misc.py @@ -10,16 +10,48 @@ def test_empty_heading() -> None: root = loads(''' -* TODO :sometag: - has no heading but still a todo? - it's a bit unclear, but seems to be highligted by emacs.. -''') + * TODO :sometag: + has no heading but still a todo? + it's a bit unclear, but seems to be highligted by emacs.. + ''') [h] = root.children assert h.todo == 'TODO' assert h.heading == '' assert h.tags == {'sometag'} +def test_dynamic_heading_edits() -> None: + content = """* TODO [#A] Heading :tag1:tag2: + Body line + Second line""" + root = loads(content) + node = root.children[0] + assert str(node) == content + + node.todo = "DONE" + assert node.todo == "DONE" + assert str(node).splitlines()[0] == "* DONE [#A] Heading :tag1:tag2:" + + node.priority = None + assert node.priority is None + assert str(node).splitlines()[0] == "* DONE Heading :tag1:tag2:" + + node.heading = "Updated heading" + assert node.heading == "Updated heading" + assert str(node).splitlines()[0] == "* DONE Updated heading :tag1:tag2:" + + node.tags = ["x", "y"] + assert node.shallow_tags == {"x", "y"} + assert str(node).splitlines()[0] == "* DONE Updated heading :x:y:" + + node.todo = None + node.priority = "B" + node.tags = [] + assert str(node).splitlines()[0] == "* [#B] Updated heading" + + assert str(node).splitlines()[1:] == [" Body line", " Second line"] + + def test_root() -> None: root = loads( ''' From a71753bb6ffa87ccc443a60b29e2cd3437d00f95 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sun, 22 Feb 2026 22:15:38 +0100 Subject: [PATCH 02/13] Allow changing the dates on a node. --- src/orgparse/node.py | 315 ++++++++++++++++++++++++++++++-- src/orgparse/tests/test_misc.py | 150 ++++++++++++++- 2 files changed, 450 insertions(+), 15 deletions(-) diff --git a/src/orgparse/node.py b/src/orgparse/node.py index 4d56913..c77d95d 100644 --- a/src/orgparse/node.py +++ b/src/orgparse/node.py @@ -6,11 +6,13 @@ from typing import ( Any, Optional, + TypeVar, Union, cast, ) from .date import ( + TIMESTAMP_RE, OrgDate, OrgDateClock, OrgDateClosed, @@ -137,6 +139,7 @@ def parse_heading_priority(heading: str) -> tuple[str, Optional[str]]: RE_HEADING_PRIORITY = re.compile(r'^\s*\[#([A-Z0-9])\] ?(.*)$') PropertyValue = Union[str, int, float] +TOrgDate = TypeVar("TOrgDate", bound=OrgDate) class LineItem: @@ -223,6 +226,175 @@ def render(self) -> str: return rendered +class SdcEntry(LineItem): + def __init__(self, label: str, date: OrgDate, raw: str) -> None: + self.label = label + self.date = date + self._raw = raw + self._dirty = False + + def update(self, date: OrgDate) -> None: + self.date = date + self._dirty = True + + def render(self) -> str: + if not self._dirty: + return self._raw + return f"{self.label}: {self.date}" + + +class SdcLine(LineItem): + _label_re = re.compile(r"(SCHEDULED|DEADLINE|CLOSED):\s+") + + def __init__(self, raw: str, parts: list[LineItem | str], entries: dict[str, SdcEntry]) -> None: + self._raw = raw + self._parts = parts + self._entries = entries + self._dirty = False + + @classmethod + def from_line(cls, line: str) -> SdcLine | None: + if line.lstrip().startswith("#"): + return None + parts: list[LineItem | str] = [] + entries: dict[str, SdcEntry] = {} + pos = 0 + for match in cls._label_re.finditer(line): + ts_match = TIMESTAMP_RE.match(line[match.end() :]) + if not ts_match: + continue + entry_start = match.start() + entry_end = match.end() + ts_match.end() + if entry_start > pos: + parts.append(line[pos:entry_start]) + label = match.group(1) + entry_text = line[entry_start:entry_end] + if label == "SCHEDULED": + date = OrgDateScheduled.from_str(entry_text) + elif label == "DEADLINE": + date = OrgDateDeadline.from_str(entry_text) + else: + date = OrgDateClosed.from_str(entry_text) + entry = SdcEntry(label, date, entry_text) + entries[label] = entry + parts.append(entry) + pos = entry_end + if not entries: + return None + if pos < len(line): + parts.append(line[pos:]) + return cls(line, parts, entries) + + @classmethod + def from_entries(cls, entries: dict[str, OrgDate]) -> SdcLine: + order = ["SCHEDULED", "DEADLINE", "CLOSED"] + parts: list[LineItem | str] = [] + raw_parts: list[str] = [] + entry_map: dict[str, SdcEntry] = {} + for label in order: + date = entries.get(label) + if date is None or not date: + continue + entry = SdcEntry(label, date, f"{label}: {date}") + entry_map[label] = entry + if raw_parts: + raw_parts.append(" ") + parts.append(" ") + raw_parts.append(entry.render()) + parts.append(entry) + raw = "".join(raw_parts) + return cls(raw, parts, entry_map) + + def update_entry(self, label: str, date: OrgDate | None) -> None: + if date is None or not date: + entry = self._entries.pop(label, None) + if entry is not None: + self._parts = [part for part in self._parts if part is not entry] + self._dirty = True + return + entry = self._entries.get(label) + if entry is None: + new_entry = SdcEntry(label, date, f"{label}: {date}") + if self._parts: + self._parts.append(" ") + self._parts.append(new_entry) + self._entries[label] = new_entry + else: + entry.update(date) + self._dirty = True + + def is_empty(self) -> bool: + return not self._entries + + def render(self) -> str: + if not self._dirty: + return self._raw + rendered_parts: list[str] = [] + for part in self._parts: + if isinstance(part, LineItem): + rendered = part.render() + if rendered: + rendered_parts.append(rendered) + else: + rendered_parts.append(part) + rendered = "".join(rendered_parts) + self._raw = rendered + self._dirty = False + return rendered + + +class ClockLine(LineItem): + _label_re = re.compile(r"^(?!#)(?P\s*CLOCK:\s+)") + + def __init__(self, raw: str, prefix: str, date: OrgDateClock, suffix: str) -> None: + self._raw = raw + self._prefix = prefix + self.date = date + self._suffix = suffix + self._dirty = False + + @classmethod + def _timestamp_span(cls, line: str, start: int) -> tuple[int, int] | None: + match = TIMESTAMP_RE.search(line, start) + if not match: + return None + span_start = match.start() + span_end = match.end() + if line[span_end : span_end + 2] == "--": + match2 = TIMESTAMP_RE.match(line[span_end + 2 :]) + if match2: + span_end = span_end + 2 + match2.end() + return (span_start, span_end) + + @classmethod + def from_line(cls, line: str) -> ClockLine | None: + match = cls._label_re.match(line) + if not match: + return None + span = cls._timestamp_span(line, match.end()) + if not span: + return None + date = OrgDateClock.from_str(line) + if not date: + return None + (ts_start, ts_end) = span + prefix = line[:ts_start] + suffix = line[ts_end:] + return cls(line, prefix, date, suffix) + + def update(self, date: OrgDateClock) -> None: + self.date = date + self._dirty = True + + def render(self) -> str: + if not self._dirty: + return self._raw + rendered = f"{self._prefix}{self.date}{self._suffix}" + self._raw = rendered + self._dirty = False + return rendered + + def parse_property(line: str) -> tuple[Optional[str], Optional[PropertyValue]]: """ Get property from given string. @@ -1157,6 +1329,14 @@ def _update_line_item(self, index: int, item: LineItem) -> None: else: self._lines_dirty = True + def _insert_line_item(self, index: int, item: LineItem) -> None: + self._line_items.insert(index, item) + self._lines_dirty = True + + def _remove_line_item(self, index: int) -> None: + del self._line_items[index] + self._lines_dirty = True + # todo hmm, not sure if it really belongs here and not to OrgRootNode? def get_file_property_list(self, property: str): # noqa: A002 """ @@ -1235,8 +1415,10 @@ def __init__(self, *args, **kwds) -> None: self._level: int | None = None self._tags = cast(list[str], None) self._todo: Optional[str] = None - self._priority = None - self._heading_line = cast(HeadingLine, None) + self._priority: Optional[str] = None + self._heading_line: HeadingLine | None = None + self._sdc_line: SdcLine | None = None + self._clock_lines: list[ClockLine] = [] self._scheduled = OrgDateScheduled(None) self._deadline = OrgDateDeadline(None) self._closed = OrgDateClosed(None) @@ -1255,6 +1437,7 @@ def _parse_pre(self): next(ilines) # skip heading except StopIteration: return + self._clock_line_indices = iter(self._find_clock_line_indices()) ilines = self._iparse_sdc(ilines) ilines = self._iparse_clock(ilines) ilines = self._iparse_properties(ilines) @@ -1263,13 +1446,14 @@ def _parse_pre(self): self._body_lines = list(ilines) def _parse_heading(self) -> None: - self._heading_line = HeadingLine.from_line(self._lines[0], self.env.all_todo_keys) - self._level = self._heading_line.level - self._tags = list(self._heading_line.tags) - self._todo = self._heading_line.todo - self._priority = self._heading_line.priority - self._heading = self._heading_line.heading - self._update_line_item(0, self._heading_line) + heading_line = HeadingLine.from_line(self._lines[0], self.env.all_todo_keys) + self._heading_line = heading_line + self._level = heading_line.level + self._tags = list(heading_line.tags) + self._todo = heading_line.todo + self._priority = heading_line.priority + self._heading = heading_line.heading + self._update_line_item(0, heading_line) def _normalize_tags(self, tags: Iterable[str] | None) -> list[str]: if tags is None: @@ -1281,7 +1465,7 @@ def _normalize_tags(self, tags: Iterable[str] | None) -> list[str]: return list(tags) def _update_heading_line(self) -> None: - if not self._heading_line: + if self._heading_line is None: return self._heading_line.todo = self._todo self._heading_line.priority = self._priority @@ -1290,6 +1474,44 @@ def _update_heading_line(self) -> None: self._heading_line.mark_dirty() self._update_line_item(0, self._heading_line) + def _find_clock_line_indices(self) -> list[int]: + indices: list[int] = [] + for index, line in enumerate(self._lines): + if OrgDateClock.from_str(line): + indices.append(index) + return indices + + def _coerce_sdc_date(self, value: Any, cls: type[TOrgDate]) -> TOrgDate: + if value is None: + return cls(None) + if isinstance(value, OrgDate): + return cls(value.start, value.end, active=value.is_active()) + return cls(value) + + def _update_sdc_entry(self, label: str, date: OrgDate) -> None: + if self._sdc_line is None: + if not date: + return + self._sdc_line = SdcLine.from_entries({label: date}) + self._insert_line_item(1, self._sdc_line) + return + self._sdc_line.update_entry(label, date) + if self._sdc_line.is_empty(): + index = self._line_items.index(self._sdc_line) + self._remove_line_item(index) + self._sdc_line = None + else: + self._lines_dirty = True + + def _format_clock_line(self, clock: OrgDateClock) -> ClockLine: + prefix = " CLOCK: " + suffix = "" + if clock.has_end(): + minutes = int(clock.duration.total_seconds() // 60) + hours, mins = divmod(minutes, 60) + suffix = f" => {hours}:{mins:02d}" + return ClockLine(f"{prefix}{clock}{suffix}", prefix, clock, suffix) + # The following ``_iparse_*`` methods are simple generator based # parser. See ``_parse_pre`` for how it is used. The principle # is simple: these methods get an iterator and returns an iterator. @@ -1307,20 +1529,41 @@ def _iparse_sdc(self, ilines: Iterator[str]) -> Iterator[str]: line = next(ilines) except StopIteration: return - (self._scheduled, self._deadline, self._closed) = parse_sdc(line) - - if not (self._scheduled or self._deadline or self._closed): - yield line # when none of them were found + sdc_line = SdcLine.from_line(line) + if sdc_line is not None: + self._sdc_line = sdc_line + self._update_line_item(1, sdc_line) + scheduled_entry = sdc_line._entries.get("SCHEDULED") + deadline_entry = sdc_line._entries.get("DEADLINE") + closed_entry = sdc_line._entries.get("CLOSED") + self._scheduled = ( + cast(OrgDateScheduled, scheduled_entry.date) if scheduled_entry is not None else OrgDateScheduled(None) + ) + self._deadline = ( + cast(OrgDateDeadline, deadline_entry.date) if deadline_entry is not None else OrgDateDeadline(None) + ) + self._closed = cast(OrgDateClosed, closed_entry.date) if closed_entry is not None else OrgDateClosed(None) + else: + (self._scheduled, self._deadline, self._closed) = parse_sdc(line) + if not (self._scheduled or self._deadline or self._closed): + yield line # when none of them were found for line in ilines: yield line def _iparse_clock(self, ilines: Iterator[str]) -> Iterator[str]: self._clocklist = [] + self._clock_lines = [] for line in ilines: cl = OrgDateClock.from_str(line) if cl: self._clocklist.append(cl) + clock_line = ClockLine.from_line(line) + if clock_line is not None: + self._clock_lines.append(clock_line) + index = next(self._clock_line_indices, None) + if index is not None: + self._update_line_item(index, clock_line) else: yield line @@ -1487,6 +1730,12 @@ def scheduled(self): """ return self._scheduled + @scheduled.setter + def scheduled(self, value: Any) -> None: + date = self._coerce_sdc_date(value, OrgDateScheduled) + self._scheduled = date + self._update_sdc_entry("SCHEDULED", date) + @property def deadline(self): """ @@ -1505,6 +1754,12 @@ def deadline(self): """ return self._deadline + @deadline.setter + def deadline(self, value: Any) -> None: + date = self._coerce_sdc_date(value, OrgDateDeadline) + self._deadline = date + self._update_sdc_entry("DEADLINE", date) + @property def closed(self): """ @@ -1523,6 +1778,12 @@ def closed(self): """ return self._closed + @closed.setter + def closed(self, value: Any) -> None: + date = self._coerce_sdc_date(value, OrgDateClosed) + self._closed = date + self._update_sdc_entry("CLOSED", date) + @property def clock(self): """ @@ -1541,6 +1802,32 @@ def clock(self): """ return self._clocklist + @clock.setter + def clock(self, value: Iterable[OrgDateClock]) -> None: + new_clocks = list(value) + self._clocklist = new_clocks + existing_indices = [i for i, item in enumerate(self._line_items) if isinstance(item, ClockLine)] + if existing_indices: + for i, clock in enumerate(new_clocks): + if i < len(existing_indices): + line_item = self._line_items[existing_indices[i]] + if isinstance(line_item, ClockLine): + line_item.update(clock) + else: + self._update_line_item(existing_indices[i], self._format_clock_line(clock)) + else: + insert_at = existing_indices[-1] + (i - len(existing_indices) + 1) + self._insert_line_item(insert_at, self._format_clock_line(clock)) + for i in reversed(existing_indices[len(new_clocks) :]): + self._remove_line_item(i) + else: + insert_at = 1 + if self._sdc_line is not None: + insert_at = self._line_items.index(self._sdc_line) + 1 + for i, clock in enumerate(new_clocks): + self._insert_line_item(insert_at + i, self._format_clock_line(clock)) + self._lines_dirty = True + def has_date(self): """ Return ``True`` if it has any kind of timestamp diff --git a/src/orgparse/tests/test_misc.py b/src/orgparse/tests/test_misc.py index 7b1c42c..9f89271 100644 --- a/src/orgparse/tests/test_misc.py +++ b/src/orgparse/tests/test_misc.py @@ -1,8 +1,9 @@ +import datetime import io import pytest -from orgparse.date import OrgDate +from orgparse.date import OrgDate, OrgDateClock from .. import load, loads from ..node import OrgEnv @@ -52,6 +53,153 @@ def test_dynamic_heading_edits() -> None: assert str(node).splitlines()[1:] == [" Body line", " Second line"] +def test_dynamic_timestamp_edits() -> None: + content = """* Node + CLOSED: [2012-02-26 Sun 21:15] SCHEDULED: <2012-02-26 Sun> + CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] => 0:05 + Body""" + root = loads(content) + node = root.children[0] + + node.deadline = datetime.date(2012, 3, 1) + sdc_line = str(node).splitlines()[1] + assert "DEADLINE: <2012-03-01 Thu>" in sdc_line + assert "SCHEDULED: <2012-02-26 Sun>" in sdc_line + assert "CLOSED: [2012-02-26 Sun 21:15]" in sdc_line + + node.closed = None + sdc_line = str(node).splitlines()[1] + assert "CLOSED:" not in sdc_line + + node.clock = [OrgDateClock((2012, 2, 26, 22, 0, 0), (2012, 2, 26, 22, 30, 0))] + clock_line = str(node).splitlines()[2] + assert "CLOCK: [2012-02-26 Sun 22:00]--[2012-02-26 Sun 22:30]" in clock_line + + +def test_add_scheduled_timestamp_line() -> None: + content = """* Node + Body""" + root = loads(content) + node = root.children[0] + + node.scheduled = datetime.date(2012, 2, 26) + assert str(node).splitlines()[:2] == ["* Node", "SCHEDULED: <2012-02-26 Sun>"] + assert str(node).splitlines()[2] == " Body" + + +def test_overwrite_existing_dates() -> None: + content = """* Node + SCHEDULED: <2012-02-26 Sun> DEADLINE: <2012-02-27 Mon> CLOSED: [2012-02-25 Sat] + Body""" + node = loads(content).children[0] + + node.scheduled = datetime.date(2012, 3, 1) + node.deadline = datetime.date(2012, 3, 2) + node.closed = datetime.datetime(2012, 3, 3, 10, 30) + + sdc_line = str(node).splitlines()[1] + assert "SCHEDULED: <2012-03-01 Thu>" in sdc_line + assert "DEADLINE: <2012-03-02 Fri>" in sdc_line + assert "CLOSED: [2012-03-03 Sat 10:30]" in sdc_line + + +def test_add_dates_to_node_without_dates() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + + node.scheduled = datetime.date(2012, 2, 26) + node.deadline = datetime.date(2012, 3, 1) + node.closed = datetime.datetime(2012, 2, 27, 8, 30) + + lines = str(node).splitlines() + assert lines[0] == "* Node" + assert lines[1] == "SCHEDULED: <2012-02-26 Sun> DEADLINE: <2012-03-01 Thu> CLOSED: [2012-02-27 Mon 08:30]" + assert lines[2] == " Body" + + +def test_add_dates_to_node_with_existing_dates() -> None: + content = """* Node + SCHEDULED: <2012-02-26 Sun> + Body""" + node = loads(content).children[0] + + node.deadline = datetime.date(2012, 3, 1) + node.closed = datetime.datetime(2012, 2, 27, 8, 30) + + sdc_line = str(node).splitlines()[1] + assert "SCHEDULED: <2012-02-26 Sun>" in sdc_line + assert "DEADLINE: <2012-03-01 Thu>" in sdc_line + assert "CLOSED: [2012-02-27 Mon 08:30]" in sdc_line + + +def test_remove_dates_from_node_without_dates() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + + node.scheduled = None + node.deadline = None + node.closed = None + + assert str(node) == content + + +def test_remove_dates_from_node_with_dates() -> None: + content = """* Node + SCHEDULED: <2012-02-26 Sun> DEADLINE: <2012-03-01 Thu> + Body""" + node = loads(content).children[0] + + node.scheduled = None + assert "SCHEDULED:" not in str(node).splitlines()[1] + + node.deadline = None + lines = str(node).splitlines() + assert lines == ["* Node", " Body"] + + +def test_duplicate_scheduled_dates() -> None: + content = """* Node + SCHEDULED: <2012-02-26 Sun> SCHEDULED: <2012-03-01 Thu> + Body""" + node = loads(content).children[0] + assert node.scheduled.start == datetime.date(2012, 3, 1) + + node.scheduled = datetime.date(2012, 4, 1) + sdc_line = str(node).splitlines()[1] + assert "SCHEDULED: <2012-02-26 Sun>" in sdc_line + assert "SCHEDULED: <2012-04-01 Sun>" in sdc_line + + +def test_multiple_clock_entries() -> None: + content = """* Node + CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] => 0:05 + CLOCK: [2012-02-26 Sun 22:00]--[2012-02-26 Sun 22:30] => 0:30 + Body""" + node = loads(content).children[0] + + node.clock = [ + OrgDateClock((2012, 2, 26, 23, 0, 0), (2012, 2, 26, 23, 30, 0)), + OrgDateClock((2012, 2, 27, 1, 0, 0), (2012, 2, 27, 1, 15, 0)), + ] + lines = str(node).splitlines() + assert "CLOCK: [2012-02-26 Sun 23:00]--[2012-02-26 Sun 23:30]" in lines[1] + assert "CLOCK: [2012-02-27 Mon 01:00]--[2012-02-27 Mon 01:15]" in lines[2] + + node.clock = [] + assert all("CLOCK:" not in line for line in str(node).splitlines()) + + +def test_setting_inactive_scheduled_date() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + + node.scheduled = OrgDate((2012, 2, 26), active=False) + assert str(node).splitlines()[1] == "SCHEDULED: [2012-02-26 Sun]" + + def test_root() -> None: root = loads( ''' From d77ee1a023e33034952f2cc16d5b9dfe4cf3dc5b Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sun, 22 Feb 2026 22:55:57 +0100 Subject: [PATCH 03/13] Allow editing properties dynamically on nodes. --- src/orgparse/node.py | 210 ++++++++++++++++++++++++++++++++ src/orgparse/tests/test_misc.py | 108 ++++++++++++++++ 2 files changed, 318 insertions(+) diff --git a/src/orgparse/node.py b/src/orgparse/node.py index c77d95d..d68f853 100644 --- a/src/orgparse/node.py +++ b/src/orgparse/node.py @@ -395,6 +395,85 @@ def render(self) -> str: return rendered +class PropertyDrawerStartLine(LineItem): + def __init__(self, raw: str) -> None: + self._raw = raw + + def render(self) -> str: + return self._raw + + +class PropertyDrawerEndLine(LineItem): + def __init__(self, raw: str) -> None: + self._raw = raw + + def render(self) -> str: + return self._raw + + +class PropertyEntryLine(LineItem): + def __init__( + self, + raw: str, + key: str, + value: PropertyValue, + render_value: str, + prefix: str, + ) -> None: + self._raw = raw + self.key = key + self.value = value + self._render_value = render_value + self._prefix = prefix + self._dirty = False + + @classmethod + def from_line(cls, line: str) -> PropertyEntryLine | None: + match = RE_PROP_LINE.match(line) + if not match: + return None + (key, value) = parse_property(line) + if key is None or value is None: + return None + return cls( + raw=line, + key=key, + value=value, + render_value=match.group("value"), + prefix=match.group("prefix"), + ) + + def update_value(self, value: PropertyValue, render_value: str) -> None: + self.value = value + self._render_value = render_value + self._dirty = True + + def render(self) -> str: + if not self._dirty: + return self._raw + if self._render_value == "": + rendered = f"{self._prefix}:{self.key}:" + else: + rendered = f"{self._prefix}:{self.key}: {self._render_value}" + self._raw = rendered + self._dirty = False + return rendered + + +class PropertyDrawer: + def __init__( + self, + start_line: PropertyDrawerStartLine, + end_line: PropertyDrawerEndLine, + entries: list[PropertyEntryLine], + indent: str, + ) -> None: + self.start_line = start_line + self.end_line = end_line + self.entries = entries + self.indent = indent + + def parse_property(line: str) -> tuple[Optional[str], Optional[PropertyValue]]: """ Get property from given string. @@ -417,6 +496,7 @@ def parse_property(line: str) -> tuple[Optional[str], Optional[PropertyValue]]: RE_PROP = re.compile(r'^\s*:(.*?):\s*(.*?)\s*$') +RE_PROP_LINE = re.compile(r'^(?P\s*):(?P[^:]+):\s*(?P.*?)\s*$') def parse_duration_to_minutes(duration: str) -> Union[float, int]: @@ -776,6 +856,7 @@ def __init__(self, env: OrgEnv, index: int | None = None) -> None: self._lines_dirty = False self._properties: dict[str, PropertyValue] = {} + self._property_drawer: PropertyDrawer | None = None self._timestamps: list[OrgDate] = [] # FIXME: use `index` argument to set index. (Currently it is @@ -1052,6 +1133,61 @@ def properties(self) -> dict[str, PropertyValue]: """ return self._properties + @properties.setter + def properties(self, value: dict[str, PropertyValue] | None) -> None: + new_props = {} if value is None else dict(value) + normalized: dict[str, PropertyValue] = {} + render_values: dict[str, str] = {} + for key, prop_value in new_props.items(): + (norm_value, render_value) = self._normalize_property_value(key, prop_value) + normalized[key] = norm_value + render_values[key] = render_value + self._properties = normalized + + if not normalized: + if self._property_drawer is not None: + start_index = self._line_items.index(self._property_drawer.start_line) + end_index = self._line_items.index(self._property_drawer.end_line) + for index in range(end_index, start_index - 1, -1): + self._remove_line_item(index) + self._property_drawer = None + self._lines_dirty = True + return + + drawer = self._property_drawer + if drawer is None: + drawer = self._create_property_drawer() + + entries_by_key: dict[str, list[PropertyEntryLine]] = {} + for entry in drawer.entries: + entries_by_key.setdefault(entry.key, []).append(entry) + + keys_to_remove = {entry.key for entry in drawer.entries if entry.key not in normalized} + if keys_to_remove: + for entry in list(drawer.entries): + if entry.key in keys_to_remove: + index = self._line_items.index(entry) + self._remove_line_item(index) + drawer.entries.remove(entry) + + for key, prop_value in normalized.items(): + render_value = render_values[key] + entries = entries_by_key.get(key, []) + if entries: + entries[-1].update_value(prop_value, render_value) + else: + insert_index = self._line_items.index(drawer.end_line) + entry = PropertyEntryLine( + raw=f"{drawer.indent}:{key}: {render_value}" if render_value else f"{drawer.indent}:{key}:", + key=key, + value=prop_value, + render_value=render_value, + prefix=drawer.indent, + ) + self._insert_line_item(insert_index, entry) + drawer.entries.append(entry) + self._lines_dirty = True + def get_property(self, key, val=None) -> Optional[PropertyValue]: """ Return property named ``key`` if exists or ``val`` otherwise. @@ -1089,6 +1225,59 @@ def _parse_comments(self): for val in special_comments.get(todokey, []): self.env.add_todo_keys(*parse_seq_todo(val)) + def _normalize_property_value(self, key: str, value: PropertyValue) -> tuple[PropertyValue, str]: + if key == "Effort" and isinstance(value, str): + return (parse_duration_to_minutes(value), value) + return (value, str(value)) + + def _create_property_drawer(self) -> PropertyDrawer: + insert_at = self._property_drawer_insert_index() + indent = self._property_drawer_indent(insert_at) + start_line = PropertyDrawerStartLine(f"{indent}:PROPERTIES:") + end_line = PropertyDrawerEndLine(f"{indent}:END:") + self._insert_line_item(insert_at, start_line) + self._insert_line_item(insert_at + 1, end_line) + drawer = PropertyDrawer(start_line, end_line, [], indent) + self._property_drawer = drawer + return drawer + + def _property_drawer_insert_index(self) -> int: + return 0 + + def _property_drawer_indent(self, insert_at: int) -> str: + if insert_at < len(self._line_items): + line = self._line_items[insert_at].render() + return line[: len(line) - len(line.lstrip(" "))] + return "" + + def _sync_property_drawer_from_lines(self) -> None: + self._property_drawer = None + index = 0 + while index < len(self._line_items): + line_item = self._line_items[index] + line = line_item.render() + if isinstance(line_item, PropertyDrawerStartLine) or line.strip() == ":PROPERTIES:": + start_line = PropertyDrawerStartLine(line) + self._update_line_item(index, start_line) + indent = line[: len(line) - len(line.lstrip(" "))] + entries: list[PropertyEntryLine] = [] + end_index = index + 1 + while end_index < len(self._line_items): + end_line_item = self._line_items[end_index] + end_line = end_line_item.render() + if isinstance(end_line_item, PropertyDrawerEndLine) or end_line.strip() == ":END:": + end_line_item = PropertyDrawerEndLine(end_line) + self._update_line_item(end_index, end_line_item) + self._property_drawer = PropertyDrawer(start_line, end_line_item, entries, indent) + return + entry = PropertyEntryLine.from_line(end_line) + if entry is not None: + self._update_line_item(end_index, entry) + entries.append(entry) + end_index += 1 + return + index += 1 + def _iparse_properties(self, ilines: Iterator[str]) -> Iterator[str]: self._properties = {} in_property_field = False @@ -1392,6 +1581,7 @@ def _parse_pre(self): ilines = self._iparse_properties(ilines) ilines = self._iparse_timestamps(ilines) self._body_lines = list(ilines) + self._sync_property_drawer_from_lines() def _iparse_timestamps(self, ilines: Iterator[str]) -> Iterator[str]: self._timestamps = [] @@ -1399,6 +1589,9 @@ def _iparse_timestamps(self, ilines: Iterator[str]) -> Iterator[str]: self._timestamps.extend(OrgDate.list_from_str(line)) yield line + def _property_drawer_indent(self, insert_at: int) -> str: # noqa: ARG002 + return "" + class OrgNode(OrgBaseNode): """ @@ -1444,6 +1637,7 @@ def _parse_pre(self): ilines = self._iparse_repeated_tasks(ilines) ilines = self._iparse_timestamps(ilines) self._body_lines = list(ilines) + self._sync_property_drawer_from_lines() def _parse_heading(self) -> None: heading_line = HeadingLine.from_line(self._lines[0], self.env.all_todo_keys) @@ -1512,6 +1706,22 @@ def _format_clock_line(self, clock: OrgDateClock) -> ClockLine: suffix = f" => {hours}:{mins:02d}" return ClockLine(f"{prefix}{clock}{suffix}", prefix, clock, suffix) + def _property_drawer_insert_index(self) -> int: + index = 1 + while index < len(self._line_items): + item = self._line_items[index] + if isinstance(item, (SdcLine, ClockLine)): + index += 1 + continue + break + return index + + def _property_drawer_indent(self, insert_at: int) -> str: + if insert_at > 0: + before = self._line_items[insert_at - 1].render() + return before[: len(before) - len(before.lstrip(" "))] or " " + return " " + # The following ``_iparse_*`` methods are simple generator based # parser. See ``_parse_pre`` for how it is used. The principle # is simple: these methods get an iterator and returns an iterator. diff --git a/src/orgparse/tests/test_misc.py b/src/orgparse/tests/test_misc.py index 9f89271..4c97986 100644 --- a/src/orgparse/tests/test_misc.py +++ b/src/orgparse/tests/test_misc.py @@ -200,6 +200,114 @@ def test_setting_inactive_scheduled_date() -> None: assert str(node).splitlines()[1] == "SCHEDULED: [2012-02-26 Sun]" +def test_overwrite_properties() -> None: + content = """* Node + :PROPERTIES: + :Owner: Jane + :Effort: 1:10 + :END: + Body""" + node = loads(content).children[0] + + node.properties = {"Owner": "Alex", "Effort": "0:30"} + lines = str(node).splitlines() + assert lines[1] == " :PROPERTIES:" + assert " :Owner: Alex" in lines + assert " :Effort: 0:30" in lines + assert lines[-1] == " Body" + assert node.properties["Effort"] == 30 + + +def test_add_properties_without_drawer() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + + node.properties = {"Owner": "Alex"} + lines = str(node).splitlines() + assert lines[:3] == ["* Node", " :PROPERTIES:", " :Owner: Alex"] + assert lines[3] == " :END:" + assert lines[4] == " Body" + + +def test_add_properties_with_existing_drawer() -> None: + content = """* Node + :PROPERTIES: + :Owner: Jane + :END: + Body""" + node = loads(content).children[0] + + node.properties = {"Owner": "Jane", "Project": "Alpha"} + lines = str(node).splitlines() + assert " :Owner: Jane" in lines + assert " :Project: Alpha" in lines + + +def test_remove_properties_without_drawer() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + + node.properties = {} + assert str(node) == content + + +def test_remove_properties_with_drawer() -> None: + content = """* Node + :PROPERTIES: + :Owner: Jane + :END: + Body""" + node = loads(content).children[0] + + node.properties = {} + assert ( + str(node) + == """* Node + Body""" + ) + + +def test_duplicate_properties_update_last() -> None: + content = """* Node + :PROPERTIES: + :Owner: Jane + :Owner: Jill + :END: + Body""" + node = loads(content).children[0] + assert node.properties["Owner"] == "Jill" + + node.properties = {"Owner": "Alex"} + lines = str(node).splitlines() + assert " :Owner: Jane" in lines + assert " :Owner: Alex" in lines + + +def test_properties_preserve_output_when_unchanged() -> None: + content = """* Node + :PROPERTIES: + :Owner: Jane + :END: + Body""" + node = loads(content).children[0] + assert str(node) == content + + +def test_root_node_properties() -> None: + content = """Intro + +:PROPERTIES: +:Title: Example +:END: + +* Node""" + root = loads(content) + root.properties = {"Title": "Updated"} + assert "Title: Updated" in str(root) + + def test_root() -> None: root = loads( ''' From 663241e1befdc5485cb7c9d115b55e48cf729360 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sun, 22 Feb 2026 23:08:41 +0100 Subject: [PATCH 04/13] Allow editing nodes repeated_tasks dynamically. --- src/orgparse/node.py | 218 ++++++++++++++++++++++++++++++++ src/orgparse/tests/test_misc.py | 110 +++++++++++++++- 2 files changed, 327 insertions(+), 1 deletion(-) diff --git a/src/orgparse/node.py b/src/orgparse/node.py index d68f853..de5610d 100644 --- a/src/orgparse/node.py +++ b/src/orgparse/node.py @@ -395,6 +395,86 @@ def render(self) -> str: return rendered +class LogbookStartLine(LineItem): + def __init__(self, raw: str) -> None: + self._raw = raw + + def render(self) -> str: + return self._raw + + +class LogbookEndLine(LineItem): + def __init__(self, raw: str) -> None: + self._raw = raw + + def render(self) -> str: + return self._raw + + +class RepeatTaskLine(LineItem): + def __init__( + self, + raw: str, + repeat: OrgDateRepeatedTask, + indent: str, + ) -> None: + self._raw = raw + self.repeat = repeat + self.indent = indent + self._dirty = False + + @classmethod + def from_line(cls, line: str) -> RepeatTaskLine | None: + if line.lstrip().startswith("#"): + return None + match = RE_REPEAT_TASK_LINE.match(line) + if not match: + return None + date = OrgDate.from_str(match.group("date")) + repeat = OrgDateRepeatedTask(date.start, match.group("todo"), match.group("done")) + return cls( + raw=line, + repeat=repeat, + indent=match.group("indent"), + ) + + @classmethod + def from_repeat(cls, repeat: OrgDateRepeatedTask, indent: str) -> RepeatTaskLine: + date_str = str(repeat) + raw = f"{indent}- State \"{repeat.after}\" from \"{repeat.before}\" {date_str}" + return cls(raw=raw, repeat=repeat, indent=indent) + + def update_repeat(self, repeat: OrgDateRepeatedTask) -> None: + self.repeat = repeat + self._dirty = True + + def render(self) -> str: + if not self._dirty: + return self._raw + date_str = str(self.repeat) + rendered = f"{self.indent}- State \"{self.repeat.after}\" from \"{self.repeat.before}\" {date_str}" + self._raw = rendered + self._dirty = False + return rendered + + +class LogbookDrawer: + def __init__( + self, + start_line: LogbookStartLine, + end_line: LogbookEndLine, + entries: list[RepeatTaskLine], + indent: str, + *, + generated: bool, + ) -> None: + self.start_line = start_line + self.end_line = end_line + self.entries = entries + self.indent = indent + self.generated = generated + + class PropertyDrawerStartLine(LineItem): def __init__(self, raw: str) -> None: self._raw = raw @@ -497,6 +577,9 @@ def parse_property(line: str) -> tuple[Optional[str], Optional[PropertyValue]]: RE_PROP = re.compile(r'^\s*:(.*?):\s*(.*?)\s*$') RE_PROP_LINE = re.compile(r'^(?P\s*):(?P[^:]+):\s*(?P.*?)\s*$') +RE_REPEAT_TASK_LINE = re.compile( + r'^(?P\s*)-\s+State\s+"(?P[^"]+)"\s+from\s+"(?P[^"]+)"\s+\[(?P[^\]]+)\]\s*$' +) def parse_duration_to_minutes(duration: str) -> Union[float, int]: @@ -1618,6 +1701,7 @@ def __init__(self, *args, **kwds) -> None: self._clocklist: list[OrgDateClock] = [] self._body_lines: list[str] = [] self._repeated_tasks: list[OrgDateRepeatedTask] = [] + self._logbook_drawers: list[LogbookDrawer] = [] # parser @@ -1638,6 +1722,7 @@ def _parse_pre(self): ilines = self._iparse_timestamps(ilines) self._body_lines = list(ilines) self._sync_property_drawer_from_lines() + self._sync_logbook_drawers_from_lines() def _parse_heading(self) -> None: heading_line = HeadingLine.from_line(self._lines[0], self.env.all_todo_keys) @@ -1706,6 +1791,81 @@ def _format_clock_line(self, clock: OrgDateClock) -> ClockLine: suffix = f" => {hours}:{mins:02d}" return ClockLine(f"{prefix}{clock}{suffix}", prefix, clock, suffix) + def _sync_logbook_drawers_from_lines(self) -> None: + self._logbook_drawers = [] + in_logbook = False + start_line: LogbookStartLine | None = None + entries: list[RepeatTaskLine] = [] + indent = "" + index = 0 + while index < len(self._line_items): + line_item = self._line_items[index] + line = line_item.render() + if line.lstrip().startswith("#"): + index += 1 + continue + if line.strip().upper() == ":LOGBOOK:": + start_line = LogbookStartLine(line) + self._update_line_item(index, start_line) + in_logbook = True + entries = [] + indent = line[: len(line) - len(line.lstrip(" "))] + index += 1 + continue + if in_logbook and line.strip().upper() == ":END:": + end_line = LogbookEndLine(line) + self._update_line_item(index, end_line) + assert start_line is not None + self._logbook_drawers.append(LogbookDrawer(start_line, end_line, entries, indent, generated=False)) + in_logbook = False + index += 1 + continue + if in_logbook: + entry = RepeatTaskLine.from_line(line) + if entry is not None: + self._update_line_item(index, entry) + entries.append(entry) + index += 1 + continue + entry = RepeatTaskLine.from_line(line) + if entry is not None: + self._update_line_item(index, entry) + index += 1 + + def _repeat_task_lines_in_order(self) -> list[RepeatTaskLine]: + return [item for item in self._line_items if isinstance(item, RepeatTaskLine)] + + def _logbook_drawer_insert_index(self) -> int: + index = 1 + while index < len(self._line_items): + item = self._line_items[index] + if isinstance(item, (SdcLine, ClockLine)): + index += 1 + continue + if isinstance(item, (PropertyDrawerStartLine, PropertyDrawerEndLine, PropertyEntryLine)): + index += 1 + continue + break + return index + + def _logbook_drawer_indent(self, insert_at: int) -> str: + if insert_at > 0: + before = self._line_items[insert_at - 1].render() + indent = before[: len(before) - len(before.lstrip(" "))] + return indent or " " + return " " + + def _create_logbook_drawer(self) -> LogbookDrawer: + insert_at = self._logbook_drawer_insert_index() + indent = self._logbook_drawer_indent(insert_at) + start_line = LogbookStartLine(f"{indent}:LOGBOOK:") + end_line = LogbookEndLine(f"{indent}:END:") + self._insert_line_item(insert_at, start_line) + self._insert_line_item(insert_at + 1, end_line) + drawer = LogbookDrawer(start_line, end_line, [], indent, generated=True) + self._logbook_drawers.append(drawer) + return drawer + def _property_drawer_insert_index(self) -> int: index = 1 while index < len(self._line_items): @@ -1787,6 +1947,9 @@ def _iparse_timestamps(self, ilines: Iterator[str]) -> Iterator[str]: def _iparse_repeated_tasks(self, ilines: Iterator[str]) -> Iterator[str]: self._repeated_tasks = [] for line in ilines: + if line.lstrip().startswith("#"): + yield line + continue match = self._repeated_tasks_re.search(line) if match: # FIXME: move this parsing to OrgDateRepeatedTask.from_str @@ -2090,6 +2253,61 @@ def repeated_tasks(self): """ return self._repeated_tasks + @repeated_tasks.setter + def repeated_tasks(self, value: Iterable[OrgDateRepeatedTask]) -> None: + new_repeats = list(value) + self._repeated_tasks = new_repeats + existing_lines = self._repeat_task_lines_in_order() + + for line, repeat in zip(existing_lines, new_repeats): + line.update_repeat(repeat) + + for line in reversed(existing_lines[len(new_repeats) :]): + index = self._line_items.index(line) + self._remove_line_item(index) + for drawer in self._logbook_drawers: + if line in drawer.entries: + drawer.entries.remove(line) + + for repeat in new_repeats[len(existing_lines) :]: + insert_drawer: LogbookDrawer | None + insert_index: int + indent: str + (insert_drawer, insert_index, indent) = self._repeat_task_insert_target(existing_lines) + entry = RepeatTaskLine.from_repeat(repeat, indent) + self._insert_line_item(insert_index, entry) + if insert_drawer is not None: + insert_drawer.entries.append(entry) + existing_lines.append(entry) + + self._remove_empty_generated_logbooks() + self._lines_dirty = True + + def _repeat_task_insert_target( + self, + existing_lines: list[RepeatTaskLine], + ) -> tuple[LogbookDrawer | None, int, str]: + if self._logbook_drawers: + drawer = self._logbook_drawers[-1] + insert_index = self._line_items.index(drawer.end_line) + return (drawer, insert_index, drawer.indent) + if existing_lines: + last_line = existing_lines[-1] + insert_index = self._line_items.index(last_line) + 1 + return (None, insert_index, last_line.indent) + drawer = self._create_logbook_drawer() + insert_index = self._line_items.index(drawer.end_line) + return (drawer, insert_index, drawer.indent) + + def _remove_empty_generated_logbooks(self) -> None: + for drawer in list(self._logbook_drawers): + if drawer.generated and not drawer.entries: + start_index = self._line_items.index(drawer.start_line) + end_index = self._line_items.index(drawer.end_line) + for index in range(end_index, start_index - 1, -1): + self._remove_line_item(index) + self._logbook_drawers.remove(drawer) + def parse_lines(lines: Iterable[str], filename, env=None) -> OrgNode: if not env: diff --git a/src/orgparse/tests/test_misc.py b/src/orgparse/tests/test_misc.py index 4c97986..bb856c9 100644 --- a/src/orgparse/tests/test_misc.py +++ b/src/orgparse/tests/test_misc.py @@ -3,7 +3,7 @@ import pytest -from orgparse.date import OrgDate, OrgDateClock +from orgparse.date import OrgDate, OrgDateClock, OrgDateRepeatedTask from .. import load, loads from ..node import OrgEnv @@ -308,6 +308,114 @@ def test_root_node_properties() -> None: assert "Title: Updated" in str(root) +def test_overwrite_repeated_tasks_in_logbook() -> None: + content = """* Node + :LOGBOOK: + - State "DONE" from "TODO" [2005-09-01 Thu 16:10] + - State "DONE" from "TODO" [2005-08-01 Mon 19:44] + :END: + Body""" + node = loads(content).children[0] + node.repeated_tasks = [ + OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), "TODO", "DONE"), + OrgDateRepeatedTask((2005, 6, 1, 10, 0, 0), "TODO", "DONE"), + ] + lines = str(node).splitlines() + assert "[2005-07-01 Fri 17:27]" in lines[2] + assert "[2005-06-01 Wed 10:00]" in lines[3] + + +def test_add_repeated_tasks_without_logbook() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + node.repeated_tasks = [ + OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), "TODO", "DONE"), + ] + lines = str(node).splitlines() + assert lines[:4] == [ + "* Node", + " :LOGBOOK:", + " - State \"DONE\" from \"TODO\" [2005-09-01 Thu 16:10]", + " :END:", + ] + assert lines[4] == " Body" + + +def test_add_repeated_tasks_with_logbook() -> None: + content = """* Node + :LOGBOOK: + CLOCK: [2012-10-26 Fri 16:01] + :END: + Body""" + node = loads(content).children[0] + node.repeated_tasks = [ + OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), "TODO", "DONE"), + ] + lines = str(node).splitlines() + assert " CLOCK: [2012-10-26 Fri 16:01]" in lines + assert " - State \"DONE\" from \"TODO\" [2005-09-01 Thu 16:10]" in lines + + +def test_remove_repeated_tasks_without_logbook() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + node.repeated_tasks = [] + assert str(node) == content + + +def test_remove_repeated_tasks_with_logbook() -> None: + content = """* Node + :LOGBOOK: + - State "DONE" from "TODO" [2005-09-01 Thu 16:10] + :END: + Body""" + node = loads(content).children[0] + node.repeated_tasks = [] + lines = str(node).splitlines() + assert lines == ["* Node", " :LOGBOOK:", " :END:", " Body"] + + +def test_multiple_logbook_drawers_update_all() -> None: + content = """* Node + :LOGBOOK: + - State "DONE" from "TODO" [2005-09-01 Thu 16:10] + :END: + :LOGBOOK: + - State "DONE" from "TODO" [2005-08-01 Mon 19:44] + :END: + Body""" + node = loads(content).children[0] + node.repeated_tasks = [ + OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), "TODO", "DONE"), + OrgDateRepeatedTask((2005, 6, 1, 10, 0, 0), "TODO", "DONE"), + ] + rendered = str(node) + assert rendered.count("[2005-07-01 Fri 17:27]") == 1 + assert rendered.count("[2005-06-01 Wed 10:00]") == 1 + + +def test_external_repeated_tasks_update() -> None: + content = """* Node + - State "DONE" from "TODO" [2005-09-01 Thu 16:10] + Body""" + node = loads(content).children[0] + node.repeated_tasks = [OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), "TODO", "DONE")] + lines = str(node).splitlines() + assert lines[1] == " - State \"DONE\" from \"TODO\" [2005-07-01 Fri 17:27]" + assert lines[2] == " Body" + + +def test_remove_generated_logbook_when_cleared() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + node.repeated_tasks = [OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), "TODO", "DONE")] + node.repeated_tasks = [] + assert str(node) == content + + def test_root() -> None: root = loads( ''' From ed6b6bf4b0f0384766311c7bb92204bb4049ce98 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sun, 22 Feb 2026 23:40:15 +0100 Subject: [PATCH 05/13] Allow editing node's body dynamically. --- src/orgparse/node.py | 60 +++++++++++++++++++++++++++++++++ src/orgparse/tests/test_misc.py | 47 ++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/src/orgparse/node.py b/src/orgparse/node.py index de5610d..69c34d2 100644 --- a/src/orgparse/node.py +++ b/src/orgparse/node.py @@ -1454,11 +1454,51 @@ def body(self) -> str: """Alias of ``.get_body(format='plain')``.""" return self.get_body() + @body.setter + def body(self, value: str) -> None: + new_lines = value.splitlines() + self._replace_body_lines(new_lines) + @property def body_rich(self) -> Iterator[Rich]: r = self.get_body(format='rich') return cast(Iterator[Rich], r) # meh.. + def _replace_body_lines(self, new_lines: list[str]) -> None: + body_indices = self._body_line_indices() + if body_indices: + insert_at = body_indices[0] + for index in reversed(body_indices): + self._remove_line_item(index) + else: + insert_at = self._body_insert_index() + for offset, line in enumerate(new_lines): + self._insert_line_item(insert_at + offset, TextLine(line)) + self._body_lines = list(new_lines) + self._lines_dirty = True + self._refresh_timestamps_after_body_change() + + def _body_line_indices(self) -> list[int]: + return [index for index, item in enumerate(self._line_items) if self._is_body_line_item(index, item)] + + def _is_body_line_item(self, index: int, item: LineItem) -> bool: # noqa: ARG002 + return not isinstance( + item, + ( + PropertyDrawerStartLine, + PropertyDrawerEndLine, + PropertyEntryLine, + ), + ) + + def _body_insert_index(self) -> int: + return len(self._line_items) + + def _refresh_timestamps_after_body_change(self) -> None: + self._timestamps = [] + for line in self._body_lines: + self._timestamps.extend(OrgDate.list_from_str(line)) + @property def heading(self) -> str: raise NotImplementedError @@ -1743,6 +1783,26 @@ def _normalize_tags(self, tags: Iterable[str] | None) -> list[str]: return sorted(tags) return list(tags) + def _is_body_line_item(self, index: int, item: LineItem) -> bool: # noqa: ARG002 + return not isinstance( + item, + ( + HeadingLine, + SdcLine, + ClockLine, + PropertyDrawerStartLine, + PropertyDrawerEndLine, + PropertyEntryLine, + RepeatTaskLine, + ), + ) + + def _refresh_timestamps_after_body_change(self) -> None: + self._timestamps = [] + self._timestamps.extend(OrgDate.list_from_str(self._heading)) + for line in self._body_lines: + self._timestamps.extend(OrgDate.list_from_str(line)) + def _update_heading_line(self) -> None: if self._heading_line is None: return diff --git a/src/orgparse/tests/test_misc.py b/src/orgparse/tests/test_misc.py index bb856c9..38a45f2 100644 --- a/src/orgparse/tests/test_misc.py +++ b/src/orgparse/tests/test_misc.py @@ -416,6 +416,53 @@ def test_remove_generated_logbook_when_cleared() -> None: assert str(node) == content +def test_body_setter_preserves_structure() -> None: + content = """* Node + SCHEDULED: <2020-01-01 Wed> + :PROPERTIES: + :Foo: bar + :END: + - State "DONE" from "TODO" [2020-01-02 Thu] + Body line 1 + Body line 2 + CLOCK: [2020-01-03 Fri 10:00]--[2020-01-03 Fri 11:00] => 1:00""" + node = loads(content).children[0] + assert str(node) == content + + node.body = "New body\nSecond line" + + expected = """* Node + SCHEDULED: <2020-01-01 Wed> + :PROPERTIES: + :Foo: bar + :END: + - State "DONE" from "TODO" [2020-01-02 Thu] +New body +Second line + CLOCK: [2020-01-03 Fri 10:00]--[2020-01-03 Fri 11:00] => 1:00""" + assert str(node) == expected + + +def test_body_setter_clears_body() -> None: + content = """* Node + Body line 1 + Body line 2""" + node = loads(content).children[0] + node.body = "" + assert node.body == "" + assert str(node) == "* Node" + + +def test_body_setter_updates_timestamps() -> None: + content = """* Node + Body with <2020-01-01 Wed> and [2020-01-02 Thu]""" + node = loads(content).children[0] + assert [str(date) for date in node.datelist] == ["<2020-01-01 Wed>", "[2020-01-02 Thu]"] + + node.body = "New <2020-02-03 Mon>" + assert [str(date) for date in node.datelist] == ["<2020-02-03 Mon>"] + + def test_root() -> None: root = loads( ''' From 9ddc10d0d9675e2bdf0024ba0be80f0085a88b6b Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sun, 22 Feb 2026 23:58:21 +0100 Subject: [PATCH 06/13] Allow re-parenting nodes --- src/orgparse/node.py | 102 ++++++++++++++++++++++++++++++++ src/orgparse/tests/test_misc.py | 64 ++++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/src/orgparse/node.py b/src/orgparse/node.py index 69c34d2..d86f3c6 100644 --- a/src/orgparse/node.py +++ b/src/orgparse/node.py @@ -1179,6 +1179,99 @@ def children(self): """ return list(self._find_children()) + @children.setter + def children(self, value: Iterable[OrgNode]) -> None: + new_children = list(value) + if len(set(new_children)) != len(new_children): + raise ValueError("Duplicate children are not allowed") + for child in new_children: + if not isinstance(child, OrgNode): + raise TypeError(f"Child must be OrgNode, got {type(child)}") + if child.env is not self.env: + raise ValueError("Child must belong to the same OrgEnv") + if self._node_contains(child, self): + raise ValueError("Cannot reparent an ancestor under its descendant") + for child in new_children: + for other in new_children: + if child is other: + continue + if self._node_contains(child, other): + raise ValueError("Cannot reparent a node alongside its descendant") + + subtree_map: dict[OrgNode, list[OrgBaseNode]] = {} + for child in new_children: + subtree_map[child] = self._collect_subtree_nodes(child) + + for start, end in reversed(self._direct_children_ranges()): + del self.env._nodes[start:end] + + for child in new_children: + self._remove_subtree_if_present(child) + + for child in new_children: + desired_level = self.level + 1 + delta = desired_level - child.level + if delta: + for node in subtree_map[child]: + if isinstance(node, OrgNode): + node._shift_level(delta) + + insert_at = self.env._nodes.index(self) + 1 + for child in new_children: + nodes = subtree_map[child] + self.env._nodes[insert_at:insert_at] = nodes + insert_at += len(nodes) + + for index, node in enumerate(self.env._nodes): + node._index = index + + def _subtree_end_index(self, start: int, level: int) -> int: + end = start + 1 + while end < len(self.env._nodes) and self.env._nodes[end].level > level: + end += 1 + return end + + def _collect_subtree_nodes(self, node: OrgBaseNode) -> list[OrgBaseNode]: + try: + start = self.env._nodes.index(node) + except ValueError as exc: + raise ValueError("Child must belong to the current OrgEnv node list") from exc + end = self._subtree_end_index(start, node.level) + return self.env._nodes[start:end] + + def _direct_children_ranges(self) -> list[tuple[int, int]]: + ranges: list[tuple[int, int]] = [] + index = self._index + 1 + while index < len(self.env._nodes) and self.env._nodes[index].level > self.level: + node = self.env._nodes[index] + if node.level == self.level + 1: + end = self._subtree_end_index(index, node.level) + ranges.append((index, end)) + index = end + else: + index += 1 + return ranges + + def _remove_subtree_if_present(self, node: OrgBaseNode) -> None: + try: + start = self.env._nodes.index(node) + except ValueError: + return + end = self._subtree_end_index(start, node.level) + del self.env._nodes[start:end] + + def _node_contains(self, ancestor: OrgBaseNode, node: OrgBaseNode) -> bool: + try: + start = self.env._nodes.index(ancestor) + except ValueError: + return False + end = self._subtree_end_index(start, ancestor.level) + try: + target_index = self.env._nodes.index(node) + except ValueError: + return False + return start < target_index < end + @property def root(self): """ @@ -1783,6 +1876,15 @@ def _normalize_tags(self, tags: Iterable[str] | None) -> list[str]: return sorted(tags) return list(tags) + def _shift_level(self, delta: int) -> None: + if self._level is None: + return + self._level += delta + if self._heading_line is not None: + self._heading_line.level = self._level + self._heading_line.mark_dirty() + self._update_line_item(0, self._heading_line) + def _is_body_line_item(self, index: int, item: LineItem) -> bool: # noqa: ARG002 return not isinstance( item, diff --git a/src/orgparse/tests/test_misc.py b/src/orgparse/tests/test_misc.py index 38a45f2..95af3cc 100644 --- a/src/orgparse/tests/test_misc.py +++ b/src/orgparse/tests/test_misc.py @@ -53,6 +53,70 @@ def test_dynamic_heading_edits() -> None: assert str(node).splitlines()[1:] == [" Body line", " Second line"] +def test_children_setter_reparents_root() -> None: + content = """* A +** A1 +* B +** B1""" + root = loads(content) + (a, b) = root.children + b1 = b.children[0] + + root.children = [b] + + assert root.children == [b] + assert b.parent is root + assert b1.parent is b + assert [node.heading for node in root[1:]] == ["B", "B1"] + assert a not in list(root) + + +def test_children_setter_reparents_and_adjusts_levels() -> None: + content = """* A +** A1 +*** A1a +* B +*** B1""" + root = loads(content) + a = root.children[0] + b = root.children[1] + b1 = b.children[0] + + a.children = [b1] + + assert a.children == [b1] + assert b.children == [] + assert b1.parent is a + assert b1.level == a.level + 1 + assert str(b1).splitlines()[0] == "** B1" + assert [node.heading for node in root[1:]] == ["A", "B1", "B"] + + +def test_children_setter_reparents_subtree_and_shifts_descendants() -> None: + content = """* A +** A1 +*** A1a +* B +** B1 +*** B1a""" + root = loads(content) + a1 = root.children[0].children[0] + b = root.children[1] + b1 = b.children[0] + b1a = b1.children[0] + + a1.children = [b1] + + assert a1.children == [b1] + assert b.children == [] + assert b1.parent is a1 + assert b1.level == a1.level + 1 + assert b1a.level == b1.level + 1 + assert str(b1).splitlines()[0] == "*** B1" + assert str(b1a).splitlines()[0] == "**** B1a" + assert "A1a" not in [node.heading for node in root[1:]] + + def test_dynamic_timestamp_edits() -> None: content = """* Node CLOSED: [2012-02-26 Sun 21:15] SCHEDULED: <2012-02-26 Sun> From 82ffbced4f816a0daeaa4e44ede26da5cb0774ed Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Mon, 23 Feb 2026 00:16:12 +0100 Subject: [PATCH 07/13] Reorganize the line items & tests --- src/orgparse/lines.py | 647 +++++++++++++++++++++ src/orgparse/node.py | 660 +--------------------- src/orgparse/tests/test_misc.py | 509 +---------------- src/orgparse/tests/test_node_mutations.py | 511 +++++++++++++++++ 4 files changed, 1181 insertions(+), 1146 deletions(-) create mode 100644 src/orgparse/lines.py create mode 100644 src/orgparse/tests/test_node_mutations.py diff --git a/src/orgparse/lines.py b/src/orgparse/lines.py new file mode 100644 index 0000000..9168fba --- /dev/null +++ b/src/orgparse/lines.py @@ -0,0 +1,647 @@ +from __future__ import annotations + +import re +from collections.abc import Sequence +from typing import Optional, Union + +from .date import ( + TIMESTAMP_RE, + OrgDate, + OrgDateClock, + OrgDateClosed, + OrgDateDeadline, + OrgDateRepeatedTask, + OrgDateScheduled, +) + +PropertyValue = Union[str, int, float] + + +def parse_heading_level(heading: str) -> tuple[str, int] | None: + """ + Get star-stripped heading and its level + + >>> parse_heading_level('* Heading') + ('Heading', 1) + >>> parse_heading_level('******** Heading') + ('Heading', 8) + >>> parse_heading_level('*') # None since no space after star + >>> parse_heading_level('*bold*') # None + >>> parse_heading_level('not heading') # None + + """ + m = RE_HEADING_STARS.search(heading) + if m is not None: + return (m.group(2), len(m.group(1))) + return None + + +RE_HEADING_STARS = re.compile(r"^\s*(\*+)\s+(.*?)\s*$") + + +def parse_heading_tags(heading: str) -> tuple[str, list[str]]: + """ + Get first tags and heading without tags + + >>> parse_heading_tags('HEADING') + ('HEADING', []) + >>> parse_heading_tags('HEADING :TAG1:TAG2:') + ('HEADING', ['TAG1', 'TAG2']) + >>> parse_heading_tags('HEADING: this is still heading :TAG1:TAG2:') + ('HEADING: this is still heading', ['TAG1', 'TAG2']) + >>> parse_heading_tags('HEADING :@tag:_tag_:') + ('HEADING', ['@tag', '_tag_']) + + Here is the spec of tags from Org Mode manual: + + Tags are normal words containing letters, numbers, ``_``, and + ``@``. Tags must be preceded and followed by a single colon, + e.g., ``:work:``. + + -- (info "(org) Tags") + + """ + match = RE_HEADING_TAGS.search(heading) + if match: + heading = match.group(1) + tagstr = match.group(2) + tags = tagstr.split(':') + else: + tags = [] + return (heading, tags) + + +# Tags are normal words containing letters, numbers, '_', and '@'. https://orgmode.org/manual/Tags.html +RE_HEADING_TAGS = re.compile(r"(.*?)\s*:([\w@:]+):\s*$") + + +def parse_heading_todos(heading: str, todo_candidates: list[str]) -> tuple[str, Optional[str]]: + """ + Get TODO keyword and heading without TODO keyword. + + >>> todos = ['TODO', 'DONE'] + >>> parse_heading_todos('Normal heading', todos) + ('Normal heading', None) + >>> parse_heading_todos('TODO Heading', todos) + ('Heading', 'TODO') + + """ + for todo in todo_candidates: + if heading == todo: + return ('', todo) + if heading.startswith(todo + ' '): + return (heading[len(todo) + 1 :], todo) + return (heading, None) + + +def parse_heading_priority(heading: str) -> tuple[str, Optional[str]]: + """ + Get priority and heading without priority field. + + >>> parse_heading_priority('HEADING') + ('HEADING', None) + >>> parse_heading_priority('[#A] HEADING') + ('HEADING', 'A') + >>> parse_heading_priority('[#0] HEADING') + ('HEADING', '0') + >>> parse_heading_priority('[#A]') + ('', 'A') + + """ + match = RE_HEADING_PRIORITY.search(heading) + if match: + return (match.group(2), match.group(1)) + else: + return (heading, None) + + +RE_HEADING_PRIORITY = re.compile(r"^\s*\[#([A-Z0-9])\] ?(.*)$") + + +def parse_property(line: str) -> tuple[Optional[str], Optional[PropertyValue]]: + """ + Get property from given string. + + >>> parse_property(':Some_property: some value') + ('Some_property', 'some value') + >>> parse_property(':Effort: 1:10') + ('Effort', 70) + + """ + prop_key = None + prop_val: Optional[Union[str, int, float]] = None + match = RE_PROP.search(line) + if match: + prop_key = match.group(1) + prop_val = match.group(2) + if prop_key == 'Effort': + prop_val = parse_duration_to_minutes(prop_val) + return (prop_key, prop_val) + + +RE_PROP = re.compile(r"^\s*:(.*?):\s*(.*?)\s*$") +RE_PROP_LINE = re.compile(r"^(?P\s*):(?P[^:]+):\s*(?P.*?)\s*$") +RE_REPEAT_TASK_LINE = re.compile( + r"^(?P\s*)-\s+State\s+\"(?P[^\"]+)\"\s+from\s+\"(?P[^\"]+)\"\s+\[(?P[^\]]+)\]\s*$" +) + + +def parse_duration_to_minutes(duration: str) -> Union[float, int]: + """ + Parse duration minutes from given string. + Convert to integer if number has no decimal points + + >>> parse_duration_to_minutes('3:12') + 192 + >>> parse_duration_to_minutes('1:23:45') + 83.75 + >>> parse_duration_to_minutes('1y 3d 3h 4min') + 530464 + >>> parse_duration_to_minutes('1d3h5min') + 1625 + >>> parse_duration_to_minutes('3d 13:35') + 5135 + >>> parse_duration_to_minutes('2.35h') + 141 + >>> parse_duration_to_minutes('10') + 10 + >>> parse_duration_to_minutes('10.') + 10 + >>> parse_duration_to_minutes('1 h') + 60 + >>> parse_duration_to_minutes('') + 0 + """ + + minutes = parse_duration_to_minutes_float(duration) + return int(minutes) if minutes.is_integer() else minutes + + +def parse_duration_to_minutes_float(duration: str) -> float: + """ + Parse duration minutes from given string. + The following code is fully compatible with the 'org-duration-to-minutes' function in org mode: + https://github.com/emacs-mirror/emacs/blob/master/lisp/org/org-duration.el + + >>> parse_duration_to_minutes_float('3:12') + 192.0 + >>> parse_duration_to_minutes_float('1:23:45') + 83.75 + >>> parse_duration_to_minutes_float('1y 3d 3h 4min') + 530464.0 + >>> parse_duration_to_minutes_float('1d3h5min') + 1625.0 + >>> parse_duration_to_minutes_float('3d 13:35') + 5135.0 + >>> parse_duration_to_minutes_float('2.35h') + 141.0 + >>> parse_duration_to_minutes_float('10') + 10.0 + >>> parse_duration_to_minutes_float('10.') + 10.0 + >>> parse_duration_to_minutes_float('1 h') + 60.0 + >>> parse_duration_to_minutes_float('') + 0.0 + """ + + match: Optional[object] + if duration == "": + return 0.0 + if isinstance(duration, float): + return float(duration) + if RE_ORG_DURATION_H_MM.fullmatch(duration): + hours, minutes, *seconds_ = map(float, duration.split(":")) + seconds = seconds_[0] if seconds_ else 0 + return seconds / 60.0 + minutes + 60 * hours + if RE_ORG_DURATION_FULL.fullmatch(duration): + minutes = 0 + for match in RE_ORG_DURATION_UNIT.finditer(duration): + value = float(match.group(1)) + unit = match.group(2) + minutes += value * ORG_DURATION_UNITS[unit] + return float(minutes) + match = RE_ORG_DURATION_MIXED.fullmatch(duration) + if match: + units_part = match.groupdict()["A"] + hms_part = match.groupdict()["B"] + return parse_duration_to_minutes_float(units_part) + parse_duration_to_minutes_float(hms_part) + if RE_FLOAT.fullmatch(duration): + return float(duration) + raise ValueError(f"Invalid duration format {duration}") + + +# Conversion factor to minutes for a duration. +ORG_DURATION_UNITS = { + "min": 1, + "h": 60, + "d": 60 * 24, + "w": 60 * 24 * 7, + "m": 60 * 24 * 30, + "y": 60 * 24 * 365.25, +} +# Regexp matching for all units. +ORG_DURATION_UNITS_RE = r"({})".format(r"|".join(ORG_DURATION_UNITS.keys())) +# Regexp matching a duration expressed with H:MM or H:MM:SS format. +# Hours can use any number of digits. +ORG_DURATION_H_MM_RE = r"[ \t]*[0-9]+(?::[0-9]{2}){1,2}[ \t]*" +RE_ORG_DURATION_H_MM = re.compile(ORG_DURATION_H_MM_RE) +# Regexp matching a duration with an unit. +# Allowed units are defined in ORG_DURATION_UNITS. +# Match group 1 contains the bare number. +# Match group 2 contains the unit. +ORG_DURATION_UNIT_RE = r"([0-9]+(?:[.][0-9]*)?)[ \t]*" + ORG_DURATION_UNITS_RE +RE_ORG_DURATION_UNIT = re.compile(ORG_DURATION_UNIT_RE) +# Regexp matching a duration expressed with units. +# Allowed units are defined in ORG_DURATION_UNITS. +ORG_DURATION_FULL_RE = rf"(?:[ \t]*{ORG_DURATION_UNIT_RE})+[ \t]*" +RE_ORG_DURATION_FULL = re.compile(ORG_DURATION_FULL_RE) +# Regexp matching a duration expressed with units and H:MM or H:MM:SS format. +# Allowed units are defined in ORG_DURATION_UNITS. +# Match group A contains units part. +# Match group B contains H:MM or H:MM:SS part. +ORG_DURATION_MIXED_RE = rf"(?P([ \t]*{ORG_DURATION_UNIT_RE})+)[ \t]*(?P[0-9]+(?::[0-9][0-9]){{1,2}})[ \t]*" +RE_ORG_DURATION_MIXED = re.compile(ORG_DURATION_MIXED_RE) +# Regexp matching float numbers. +RE_FLOAT = re.compile(r"[0-9]+([.][0-9]*)?") + + +class LineItem: + def render(self) -> str: + raise NotImplementedError + + +class TextLine(LineItem): + def __init__(self, raw: str) -> None: + self._raw = raw + + def render(self) -> str: + return self._raw + + +class HeadingLine(LineItem): + def __init__( + self, + raw: str, + level: int, + todo: Optional[str], + priority: Optional[str], + heading: str, + tags: Sequence[str], + ) -> None: + self._raw = raw + self.level = level + self.todo = todo + self.priority = priority + self.heading = heading + self.tags = list(tags) + self._dirty = False + + @classmethod + def from_line(cls, line: str, todo_candidates: list[str]) -> HeadingLine: + heading_level = parse_heading_level(line) + if heading_level is None: + raise ValueError(f"Invalid heading line: {line!r}") + (heading, level) = heading_level + (heading, tags) = parse_heading_tags(heading) + (heading, todo) = parse_heading_todos(heading, todo_candidates) + (heading, priority) = parse_heading_priority(heading) + return cls( + raw=line, + level=level, + todo=todo, + priority=priority, + heading=heading, + tags=tags, + ) + + def mark_dirty(self) -> None: + self._dirty = True + + def render(self) -> str: + if not self._dirty: + return self._raw + + stars = "*" * self.level + tokens: list[str] = [] + if self.todo: + tokens.append(self.todo) + if self.priority: + tokens.append(f"[#{self.priority}]") + if self.heading: + tokens.append(self.heading) + + if not tokens and not self.tags: + rendered = f"{stars} " + self._raw = rendered + self._dirty = False + return rendered + + rendered = f"{stars} " + if tokens: + rendered += " ".join(tokens) + if self.tags: + if tokens: + rendered += " " + rendered += ":" + ":".join(self.tags) + ":" + + self._raw = rendered + self._dirty = False + return rendered + + +class SdcEntry(LineItem): + def __init__(self, label: str, date: OrgDate, raw: str) -> None: + self.label = label + self.date = date + self._raw = raw + self._dirty = False + + def update(self, date: OrgDate) -> None: + self.date = date + self._dirty = True + + def render(self) -> str: + if not self._dirty: + return self._raw + return f"{self.label}: {self.date}" + + +class SdcLine(LineItem): + _label_re = re.compile(r"(SCHEDULED|DEADLINE|CLOSED):\s+") + + def __init__(self, raw: str, parts: list[LineItem | str], entries: dict[str, SdcEntry]) -> None: + self._raw = raw + self._parts = parts + self._entries = entries + self._dirty = False + + @classmethod + def from_line(cls, line: str) -> SdcLine | None: + if line.lstrip().startswith("#"): + return None + parts: list[LineItem | str] = [] + entries: dict[str, SdcEntry] = {} + pos = 0 + for match in cls._label_re.finditer(line): + ts_match = TIMESTAMP_RE.match(line[match.end() :]) + if not ts_match: + continue + entry_start = match.start() + entry_end = match.end() + ts_match.end() + if entry_start > pos: + parts.append(line[pos:entry_start]) + label = match.group(1) + entry_text = line[entry_start:entry_end] + if label == "SCHEDULED": + date = OrgDateScheduled.from_str(entry_text) + elif label == "DEADLINE": + date = OrgDateDeadline.from_str(entry_text) + else: + date = OrgDateClosed.from_str(entry_text) + entry = SdcEntry(label, date, entry_text) + entries[label] = entry + parts.append(entry) + pos = entry_end + if not entries: + return None + if pos < len(line): + parts.append(line[pos:]) + return cls(line, parts, entries) + + @classmethod + def from_entries(cls, entries: dict[str, OrgDate]) -> SdcLine: + order = ["SCHEDULED", "DEADLINE", "CLOSED"] + parts: list[LineItem | str] = [] + raw_parts: list[str] = [] + entry_map: dict[str, SdcEntry] = {} + for label in order: + date = entries.get(label) + if date is None or not date: + continue + entry = SdcEntry(label, date, f"{label}: {date}") + entry_map[label] = entry + if raw_parts: + raw_parts.append(" ") + parts.append(" ") + raw_parts.append(entry.render()) + parts.append(entry) + raw = "".join(raw_parts) + return cls(raw, parts, entry_map) + + def update_entry(self, label: str, date: OrgDate | None) -> None: + if date is None or not date: + entry = self._entries.pop(label, None) + if entry is not None: + self._parts = [part for part in self._parts if part is not entry] + self._dirty = True + return + entry = self._entries.get(label) + if entry is None: + new_entry = SdcEntry(label, date, f"{label}: {date}") + if self._parts: + self._parts.append(" ") + self._parts.append(new_entry) + self._entries[label] = new_entry + else: + entry.update(date) + self._dirty = True + + def is_empty(self) -> bool: + return not self._entries + + def render(self) -> str: + if not self._dirty: + return self._raw + rendered_parts: list[str] = [] + for part in self._parts: + if isinstance(part, LineItem): + rendered = part.render() + if rendered: + rendered_parts.append(rendered) + else: + rendered_parts.append(part) + rendered = "".join(rendered_parts) + self._raw = rendered + self._dirty = False + return rendered + + +class ClockLine(LineItem): + _label_re = re.compile(r"^(?!#)(?P\s*CLOCK:\s+)") + + def __init__(self, raw: str, prefix: str, date: OrgDateClock, suffix: str) -> None: + self._raw = raw + self._prefix = prefix + self.date = date + self._suffix = suffix + self._dirty = False + + @classmethod + def _timestamp_span(cls, line: str, start: int) -> tuple[int, int] | None: + match = TIMESTAMP_RE.search(line, start) + if not match: + return None + span_start = match.start() + span_end = match.end() + if line[span_end : span_end + 2] == "--": + match2 = TIMESTAMP_RE.match(line[span_end + 2 :]) + if match2: + span_end = span_end + 2 + match2.end() + return (span_start, span_end) + + @classmethod + def from_line(cls, line: str) -> ClockLine | None: + match = cls._label_re.match(line) + if not match: + return None + span = cls._timestamp_span(line, match.end()) + if not span: + return None + date = OrgDateClock.from_str(line) + if not date: + return None + (ts_start, ts_end) = span + prefix = line[:ts_start] + suffix = line[ts_end:] + return cls(line, prefix, date, suffix) + + def update(self, date: OrgDateClock) -> None: + self.date = date + self._dirty = True + + def render(self) -> str: + if not self._dirty: + return self._raw + rendered = f"{self._prefix}{self.date}{self._suffix}" + self._raw = rendered + self._dirty = False + return rendered + + +class LogbookStartLine(LineItem): + def __init__(self, raw: str) -> None: + self._raw = raw + + def render(self) -> str: + return self._raw + + +class LogbookEndLine(LineItem): + def __init__(self, raw: str) -> None: + self._raw = raw + + def render(self) -> str: + return self._raw + + +class RepeatTaskLine(LineItem): + def __init__( + self, + raw: str, + repeat: OrgDateRepeatedTask, + indent: str, + ) -> None: + self._raw = raw + self.repeat = repeat + self.indent = indent + self._dirty = False + + @classmethod + def from_line(cls, line: str) -> RepeatTaskLine | None: + if line.lstrip().startswith("#"): + return None + match = RE_REPEAT_TASK_LINE.match(line) + if not match: + return None + date = OrgDate.from_str(match.group("date")) + repeat = OrgDateRepeatedTask(date.start, match.group("todo"), match.group("done")) + return cls( + raw=line, + repeat=repeat, + indent=match.group("indent"), + ) + + @classmethod + def from_repeat(cls, repeat: OrgDateRepeatedTask, indent: str) -> RepeatTaskLine: + date_str = str(repeat) + raw = f"{indent}- State \"{repeat.after}\" from \"{repeat.before}\" {date_str}" + return cls(raw=raw, repeat=repeat, indent=indent) + + def update_repeat(self, repeat: OrgDateRepeatedTask) -> None: + self.repeat = repeat + self._dirty = True + + def render(self) -> str: + if not self._dirty: + return self._raw + date_str = str(self.repeat) + rendered = f"{self.indent}- State \"{self.repeat.after}\" from \"{self.repeat.before}\" {date_str}" + self._raw = rendered + self._dirty = False + return rendered + + +class PropertyDrawerStartLine(LineItem): + def __init__(self, raw: str) -> None: + self._raw = raw + + def render(self) -> str: + return self._raw + + +class PropertyDrawerEndLine(LineItem): + def __init__(self, raw: str) -> None: + self._raw = raw + + def render(self) -> str: + return self._raw + + +class PropertyEntryLine(LineItem): + def __init__( + self, + raw: str, + key: str, + value: PropertyValue, + render_value: str, + prefix: str, + ) -> None: + self._raw = raw + self.key = key + self.value = value + self._render_value = render_value + self._prefix = prefix + self._dirty = False + + @classmethod + def from_line(cls, line: str) -> PropertyEntryLine | None: + match = RE_PROP_LINE.match(line) + if not match: + return None + (key, value) = parse_property(line) + if key is None or value is None: + return None + return cls( + raw=line, + key=key, + value=value, + render_value=match.group("value"), + prefix=match.group("prefix"), + ) + + def update_value(self, value: PropertyValue, render_value: str) -> None: + self.value = value + self._render_value = render_value + self._dirty = True + + def render(self) -> str: + if not self._dirty: + return self._raw + if self._render_value == "": + rendered = f"{self._prefix}:{self.key}:" + else: + rendered = f"{self._prefix}:{self.key}: {self._render_value}" + self._raw = rendered + self._dirty = False + return rendered diff --git a/src/orgparse/node.py b/src/orgparse/node.py index d86f3c6..beaf791 100644 --- a/src/orgparse/node.py +++ b/src/orgparse/node.py @@ -3,16 +3,9 @@ import itertools import re from collections.abc import Iterable, Iterator, Sequence -from typing import ( - Any, - Optional, - TypeVar, - Union, - cast, -) +from typing import Any, Optional, TypeVar, cast from .date import ( - TIMESTAMP_RE, OrgDate, OrgDateClock, OrgDateClosed, @@ -23,6 +16,27 @@ ) from .extra import Rich, to_rich_text from .inline import to_plain_text +from .lines import ( # noqa: F401 + ClockLine, + HeadingLine, + LineItem, + LogbookEndLine, + LogbookStartLine, + PropertyDrawerEndLine, + PropertyDrawerStartLine, + PropertyEntryLine, + PropertyValue, + RepeatTaskLine, + SdcLine, + TextLine, + parse_duration_to_minutes, + parse_duration_to_minutes_float, + parse_heading_level, + parse_heading_priority, + parse_heading_tags, + parse_heading_todos, + parse_property, +) def lines_to_chunks(lines: Iterable[str]) -> Iterable[list[str]]: @@ -38,426 +52,9 @@ def lines_to_chunks(lines: Iterable[str]) -> Iterable[list[str]]: RE_NODE_HEADER = re.compile(r"^\s*\*+ ") -def parse_heading_level(heading: str) -> tuple[str, int] | None: - """ - Get star-stripped heading and its level - - >>> parse_heading_level('* Heading') - ('Heading', 1) - >>> parse_heading_level('******** Heading') - ('Heading', 8) - >>> parse_heading_level('*') # None since no space after star - >>> parse_heading_level('*bold*') # None - >>> parse_heading_level('not heading') # None - - """ - m = RE_HEADING_STARS.search(heading) - if m is not None: - return (m.group(2), len(m.group(1))) - return None - - -RE_HEADING_STARS = re.compile(r'^\s*(\*+)\s+(.*?)\s*$') - - -def parse_heading_tags(heading: str) -> tuple[str, list[str]]: - """ - Get first tags and heading without tags - - >>> parse_heading_tags('HEADING') - ('HEADING', []) - >>> parse_heading_tags('HEADING :TAG1:TAG2:') - ('HEADING', ['TAG1', 'TAG2']) - >>> parse_heading_tags('HEADING: this is still heading :TAG1:TAG2:') - ('HEADING: this is still heading', ['TAG1', 'TAG2']) - >>> parse_heading_tags('HEADING :@tag:_tag_:') - ('HEADING', ['@tag', '_tag_']) - - Here is the spec of tags from Org Mode manual: - - Tags are normal words containing letters, numbers, ``_``, and - ``@``. Tags must be preceded and followed by a single colon, - e.g., ``:work:``. - - -- (info "(org) Tags") - - """ - match = RE_HEADING_TAGS.search(heading) - if match: - heading = match.group(1) - tagstr = match.group(2) - tags = tagstr.split(':') - else: - tags = [] - return (heading, tags) - - -# Tags are normal words containing letters, numbers, '_', and '@'. https://orgmode.org/manual/Tags.html -RE_HEADING_TAGS = re.compile(r'(.*?)\s*:([\w@:]+):\s*$') - - -def parse_heading_todos(heading: str, todo_candidates: list[str]) -> tuple[str, Optional[str]]: - """ - Get TODO keyword and heading without TODO keyword. - - >>> todos = ['TODO', 'DONE'] - >>> parse_heading_todos('Normal heading', todos) - ('Normal heading', None) - >>> parse_heading_todos('TODO Heading', todos) - ('Heading', 'TODO') - - """ - for todo in todo_candidates: - if heading == todo: - return ('', todo) - if heading.startswith(todo + ' '): - return (heading[len(todo) + 1 :], todo) - return (heading, None) - - -def parse_heading_priority(heading: str) -> tuple[str, Optional[str]]: - """ - Get priority and heading without priority field. - - >>> parse_heading_priority('HEADING') - ('HEADING', None) - >>> parse_heading_priority('[#A] HEADING') - ('HEADING', 'A') - >>> parse_heading_priority('[#0] HEADING') - ('HEADING', '0') - >>> parse_heading_priority('[#A]') - ('', 'A') - - """ - match = RE_HEADING_PRIORITY.search(heading) - if match: - return (match.group(2), match.group(1)) - else: - return (heading, None) - - -RE_HEADING_PRIORITY = re.compile(r'^\s*\[#([A-Z0-9])\] ?(.*)$') - -PropertyValue = Union[str, int, float] TOrgDate = TypeVar("TOrgDate", bound=OrgDate) -class LineItem: - def render(self) -> str: - raise NotImplementedError - - -class TextLine(LineItem): - def __init__(self, raw: str) -> None: - self._raw = raw - - def render(self) -> str: - return self._raw - - -class HeadingLine(LineItem): - def __init__( - self, - raw: str, - level: int, - todo: Optional[str], - priority: Optional[str], - heading: str, - tags: Sequence[str], - ) -> None: - self._raw = raw - self.level = level - self.todo = todo - self.priority = priority - self.heading = heading - self.tags = list(tags) - self._dirty = False - - @classmethod - def from_line(cls, line: str, todo_candidates: list[str]) -> HeadingLine: - heading_level = parse_heading_level(line) - if heading_level is None: - raise ValueError(f"Invalid heading line: {line!r}") - (heading, level) = heading_level - (heading, tags) = parse_heading_tags(heading) - (heading, todo) = parse_heading_todos(heading, todo_candidates) - (heading, priority) = parse_heading_priority(heading) - return cls( - raw=line, - level=level, - todo=todo, - priority=priority, - heading=heading, - tags=tags, - ) - - def mark_dirty(self) -> None: - self._dirty = True - - def render(self) -> str: - if not self._dirty: - return self._raw - - stars = "*" * self.level - tokens: list[str] = [] - if self.todo: - tokens.append(self.todo) - if self.priority: - tokens.append(f"[#{self.priority}]") - if self.heading: - tokens.append(self.heading) - - if not tokens and not self.tags: - rendered = f"{stars} " - self._raw = rendered - self._dirty = False - return rendered - - rendered = f"{stars} " - if tokens: - rendered += " ".join(tokens) - if self.tags: - if tokens: - rendered += " " - rendered += ":" + ":".join(self.tags) + ":" - - self._raw = rendered - self._dirty = False - return rendered - - -class SdcEntry(LineItem): - def __init__(self, label: str, date: OrgDate, raw: str) -> None: - self.label = label - self.date = date - self._raw = raw - self._dirty = False - - def update(self, date: OrgDate) -> None: - self.date = date - self._dirty = True - - def render(self) -> str: - if not self._dirty: - return self._raw - return f"{self.label}: {self.date}" - - -class SdcLine(LineItem): - _label_re = re.compile(r"(SCHEDULED|DEADLINE|CLOSED):\s+") - - def __init__(self, raw: str, parts: list[LineItem | str], entries: dict[str, SdcEntry]) -> None: - self._raw = raw - self._parts = parts - self._entries = entries - self._dirty = False - - @classmethod - def from_line(cls, line: str) -> SdcLine | None: - if line.lstrip().startswith("#"): - return None - parts: list[LineItem | str] = [] - entries: dict[str, SdcEntry] = {} - pos = 0 - for match in cls._label_re.finditer(line): - ts_match = TIMESTAMP_RE.match(line[match.end() :]) - if not ts_match: - continue - entry_start = match.start() - entry_end = match.end() + ts_match.end() - if entry_start > pos: - parts.append(line[pos:entry_start]) - label = match.group(1) - entry_text = line[entry_start:entry_end] - if label == "SCHEDULED": - date = OrgDateScheduled.from_str(entry_text) - elif label == "DEADLINE": - date = OrgDateDeadline.from_str(entry_text) - else: - date = OrgDateClosed.from_str(entry_text) - entry = SdcEntry(label, date, entry_text) - entries[label] = entry - parts.append(entry) - pos = entry_end - if not entries: - return None - if pos < len(line): - parts.append(line[pos:]) - return cls(line, parts, entries) - - @classmethod - def from_entries(cls, entries: dict[str, OrgDate]) -> SdcLine: - order = ["SCHEDULED", "DEADLINE", "CLOSED"] - parts: list[LineItem | str] = [] - raw_parts: list[str] = [] - entry_map: dict[str, SdcEntry] = {} - for label in order: - date = entries.get(label) - if date is None or not date: - continue - entry = SdcEntry(label, date, f"{label}: {date}") - entry_map[label] = entry - if raw_parts: - raw_parts.append(" ") - parts.append(" ") - raw_parts.append(entry.render()) - parts.append(entry) - raw = "".join(raw_parts) - return cls(raw, parts, entry_map) - - def update_entry(self, label: str, date: OrgDate | None) -> None: - if date is None or not date: - entry = self._entries.pop(label, None) - if entry is not None: - self._parts = [part for part in self._parts if part is not entry] - self._dirty = True - return - entry = self._entries.get(label) - if entry is None: - new_entry = SdcEntry(label, date, f"{label}: {date}") - if self._parts: - self._parts.append(" ") - self._parts.append(new_entry) - self._entries[label] = new_entry - else: - entry.update(date) - self._dirty = True - - def is_empty(self) -> bool: - return not self._entries - - def render(self) -> str: - if not self._dirty: - return self._raw - rendered_parts: list[str] = [] - for part in self._parts: - if isinstance(part, LineItem): - rendered = part.render() - if rendered: - rendered_parts.append(rendered) - else: - rendered_parts.append(part) - rendered = "".join(rendered_parts) - self._raw = rendered - self._dirty = False - return rendered - - -class ClockLine(LineItem): - _label_re = re.compile(r"^(?!#)(?P\s*CLOCK:\s+)") - - def __init__(self, raw: str, prefix: str, date: OrgDateClock, suffix: str) -> None: - self._raw = raw - self._prefix = prefix - self.date = date - self._suffix = suffix - self._dirty = False - - @classmethod - def _timestamp_span(cls, line: str, start: int) -> tuple[int, int] | None: - match = TIMESTAMP_RE.search(line, start) - if not match: - return None - span_start = match.start() - span_end = match.end() - if line[span_end : span_end + 2] == "--": - match2 = TIMESTAMP_RE.match(line[span_end + 2 :]) - if match2: - span_end = span_end + 2 + match2.end() - return (span_start, span_end) - - @classmethod - def from_line(cls, line: str) -> ClockLine | None: - match = cls._label_re.match(line) - if not match: - return None - span = cls._timestamp_span(line, match.end()) - if not span: - return None - date = OrgDateClock.from_str(line) - if not date: - return None - (ts_start, ts_end) = span - prefix = line[:ts_start] - suffix = line[ts_end:] - return cls(line, prefix, date, suffix) - - def update(self, date: OrgDateClock) -> None: - self.date = date - self._dirty = True - - def render(self) -> str: - if not self._dirty: - return self._raw - rendered = f"{self._prefix}{self.date}{self._suffix}" - self._raw = rendered - self._dirty = False - return rendered - - -class LogbookStartLine(LineItem): - def __init__(self, raw: str) -> None: - self._raw = raw - - def render(self) -> str: - return self._raw - - -class LogbookEndLine(LineItem): - def __init__(self, raw: str) -> None: - self._raw = raw - - def render(self) -> str: - return self._raw - - -class RepeatTaskLine(LineItem): - def __init__( - self, - raw: str, - repeat: OrgDateRepeatedTask, - indent: str, - ) -> None: - self._raw = raw - self.repeat = repeat - self.indent = indent - self._dirty = False - - @classmethod - def from_line(cls, line: str) -> RepeatTaskLine | None: - if line.lstrip().startswith("#"): - return None - match = RE_REPEAT_TASK_LINE.match(line) - if not match: - return None - date = OrgDate.from_str(match.group("date")) - repeat = OrgDateRepeatedTask(date.start, match.group("todo"), match.group("done")) - return cls( - raw=line, - repeat=repeat, - indent=match.group("indent"), - ) - - @classmethod - def from_repeat(cls, repeat: OrgDateRepeatedTask, indent: str) -> RepeatTaskLine: - date_str = str(repeat) - raw = f"{indent}- State \"{repeat.after}\" from \"{repeat.before}\" {date_str}" - return cls(raw=raw, repeat=repeat, indent=indent) - - def update_repeat(self, repeat: OrgDateRepeatedTask) -> None: - self.repeat = repeat - self._dirty = True - - def render(self) -> str: - if not self._dirty: - return self._raw - date_str = str(self.repeat) - rendered = f"{self.indent}- State \"{self.repeat.after}\" from \"{self.repeat.before}\" {date_str}" - self._raw = rendered - self._dirty = False - return rendered - - class LogbookDrawer: def __init__( self, @@ -475,71 +72,6 @@ def __init__( self.generated = generated -class PropertyDrawerStartLine(LineItem): - def __init__(self, raw: str) -> None: - self._raw = raw - - def render(self) -> str: - return self._raw - - -class PropertyDrawerEndLine(LineItem): - def __init__(self, raw: str) -> None: - self._raw = raw - - def render(self) -> str: - return self._raw - - -class PropertyEntryLine(LineItem): - def __init__( - self, - raw: str, - key: str, - value: PropertyValue, - render_value: str, - prefix: str, - ) -> None: - self._raw = raw - self.key = key - self.value = value - self._render_value = render_value - self._prefix = prefix - self._dirty = False - - @classmethod - def from_line(cls, line: str) -> PropertyEntryLine | None: - match = RE_PROP_LINE.match(line) - if not match: - return None - (key, value) = parse_property(line) - if key is None or value is None: - return None - return cls( - raw=line, - key=key, - value=value, - render_value=match.group("value"), - prefix=match.group("prefix"), - ) - - def update_value(self, value: PropertyValue, render_value: str) -> None: - self.value = value - self._render_value = render_value - self._dirty = True - - def render(self) -> str: - if not self._dirty: - return self._raw - if self._render_value == "": - rendered = f"{self._prefix}:{self.key}:" - else: - rendered = f"{self._prefix}:{self.key}: {self._render_value}" - self._raw = rendered - self._dirty = False - return rendered - - class PropertyDrawer: def __init__( self, @@ -554,154 +86,6 @@ def __init__( self.indent = indent -def parse_property(line: str) -> tuple[Optional[str], Optional[PropertyValue]]: - """ - Get property from given string. - - >>> parse_property(':Some_property: some value') - ('Some_property', 'some value') - >>> parse_property(':Effort: 1:10') - ('Effort', 70) - - """ - prop_key = None - prop_val: Optional[Union[str, int, float]] = None - match = RE_PROP.search(line) - if match: - prop_key = match.group(1) - prop_val = match.group(2) - if prop_key == 'Effort': - prop_val = parse_duration_to_minutes(prop_val) - return (prop_key, prop_val) - - -RE_PROP = re.compile(r'^\s*:(.*?):\s*(.*?)\s*$') -RE_PROP_LINE = re.compile(r'^(?P\s*):(?P[^:]+):\s*(?P.*?)\s*$') -RE_REPEAT_TASK_LINE = re.compile( - r'^(?P\s*)-\s+State\s+"(?P[^"]+)"\s+from\s+"(?P[^"]+)"\s+\[(?P[^\]]+)\]\s*$' -) - - -def parse_duration_to_minutes(duration: str) -> Union[float, int]: - """ - Parse duration minutes from given string. - Convert to integer if number has no decimal points - - >>> parse_duration_to_minutes('3:12') - 192 - >>> parse_duration_to_minutes('1:23:45') - 83.75 - >>> parse_duration_to_minutes('1y 3d 3h 4min') - 530464 - >>> parse_duration_to_minutes('1d3h5min') - 1625 - >>> parse_duration_to_minutes('3d 13:35') - 5135 - >>> parse_duration_to_minutes('2.35h') - 141 - >>> parse_duration_to_minutes('10') - 10 - >>> parse_duration_to_minutes('10.') - 10 - >>> parse_duration_to_minutes('1 h') - 60 - >>> parse_duration_to_minutes('') - 0 - """ - - minutes = parse_duration_to_minutes_float(duration) - return int(minutes) if minutes.is_integer() else minutes - - -def parse_duration_to_minutes_float(duration: str) -> float: - """ - Parse duration minutes from given string. - The following code is fully compatible with the 'org-duration-to-minutes' function in org mode: - https://github.com/emacs-mirror/emacs/blob/master/lisp/org/org-duration.el - - >>> parse_duration_to_minutes_float('3:12') - 192.0 - >>> parse_duration_to_minutes_float('1:23:45') - 83.75 - >>> parse_duration_to_minutes_float('1y 3d 3h 4min') - 530464.0 - >>> parse_duration_to_minutes_float('1d3h5min') - 1625.0 - >>> parse_duration_to_minutes_float('3d 13:35') - 5135.0 - >>> parse_duration_to_minutes_float('2.35h') - 141.0 - >>> parse_duration_to_minutes_float('10') - 10.0 - >>> parse_duration_to_minutes_float('10.') - 10.0 - >>> parse_duration_to_minutes_float('1 h') - 60.0 - >>> parse_duration_to_minutes_float('') - 0.0 - """ - - match: Optional[Any] - if duration == "": - return 0.0 - if isinstance(duration, float): - return float(duration) - if RE_ORG_DURATION_H_MM.fullmatch(duration): - hours, minutes, *seconds_ = map(float, duration.split(":")) - seconds = seconds_[0] if seconds_ else 0 - return seconds / 60.0 + minutes + 60 * hours - if RE_ORG_DURATION_FULL.fullmatch(duration): - minutes = 0 - for match in RE_ORG_DURATION_UNIT.finditer(duration): - value = float(match.group(1)) - unit = match.group(2) - minutes += value * ORG_DURATION_UNITS[unit] - return float(minutes) - match = RE_ORG_DURATION_MIXED.fullmatch(duration) - if match: - units_part = match.groupdict()['A'] - hms_part = match.groupdict()['B'] - return parse_duration_to_minutes_float(units_part) + parse_duration_to_minutes_float(hms_part) - if RE_FLOAT.fullmatch(duration): - return float(duration) - raise ValueError(f"Invalid duration format {duration}") - - -# Conversion factor to minutes for a duration. -ORG_DURATION_UNITS = { - "min": 1, - "h": 60, - "d": 60 * 24, - "w": 60 * 24 * 7, - "m": 60 * 24 * 30, - "y": 60 * 24 * 365.25, -} -# Regexp matching for all units. -ORG_DURATION_UNITS_RE = r'({})'.format(r'|'.join(ORG_DURATION_UNITS.keys())) -# Regexp matching a duration expressed with H:MM or H:MM:SS format. -# Hours can use any number of digits. -ORG_DURATION_H_MM_RE = r'[ \t]*[0-9]+(?::[0-9]{2}){1,2}[ \t]*' -RE_ORG_DURATION_H_MM = re.compile(ORG_DURATION_H_MM_RE) -# Regexp matching a duration with an unit. -# Allowed units are defined in ORG_DURATION_UNITS. -# Match group 1 contains the bare number. -# Match group 2 contains the unit. -ORG_DURATION_UNIT_RE = r'([0-9]+(?:[.][0-9]*)?)[ \t]*' + ORG_DURATION_UNITS_RE -RE_ORG_DURATION_UNIT = re.compile(ORG_DURATION_UNIT_RE) -# Regexp matching a duration expressed with units. -# Allowed units are defined in ORG_DURATION_UNITS. -ORG_DURATION_FULL_RE = rf'(?:[ \t]*{ORG_DURATION_UNIT_RE})+[ \t]*' -RE_ORG_DURATION_FULL = re.compile(ORG_DURATION_FULL_RE) -# Regexp matching a duration expressed with units and H:MM or H:MM:SS format. -# Allowed units are defined in ORG_DURATION_UNITS. -# Match group A contains units part. -# Match group B contains H:MM or H:MM:SS part. -ORG_DURATION_MIXED_RE = rf'(?P([ \t]*{ORG_DURATION_UNIT_RE})+)[ \t]*(?P[0-9]+(?::[0-9][0-9]){{1,2}})[ \t]*' -RE_ORG_DURATION_MIXED = re.compile(ORG_DURATION_MIXED_RE) -# Regexp matching float numbers. -RE_FLOAT = re.compile(r'[0-9]+([.][0-9]*)?') - - # -> Optional[Tuple[str, Sequence[str]]]: # todo wtf?? it says 'ABCMeta isn't subscriptable??' def parse_comment(line: str): """ diff --git a/src/orgparse/tests/test_misc.py b/src/orgparse/tests/test_misc.py index 95af3cc..b849d97 100644 --- a/src/orgparse/tests/test_misc.py +++ b/src/orgparse/tests/test_misc.py @@ -1,9 +1,8 @@ -import datetime import io import pytest -from orgparse.date import OrgDate, OrgDateClock, OrgDateRepeatedTask +from orgparse.date import OrgDate from .. import load, loads from ..node import OrgEnv @@ -21,512 +20,6 @@ def test_empty_heading() -> None: assert h.tags == {'sometag'} -def test_dynamic_heading_edits() -> None: - content = """* TODO [#A] Heading :tag1:tag2: - Body line - Second line""" - root = loads(content) - node = root.children[0] - assert str(node) == content - - node.todo = "DONE" - assert node.todo == "DONE" - assert str(node).splitlines()[0] == "* DONE [#A] Heading :tag1:tag2:" - - node.priority = None - assert node.priority is None - assert str(node).splitlines()[0] == "* DONE Heading :tag1:tag2:" - - node.heading = "Updated heading" - assert node.heading == "Updated heading" - assert str(node).splitlines()[0] == "* DONE Updated heading :tag1:tag2:" - - node.tags = ["x", "y"] - assert node.shallow_tags == {"x", "y"} - assert str(node).splitlines()[0] == "* DONE Updated heading :x:y:" - - node.todo = None - node.priority = "B" - node.tags = [] - assert str(node).splitlines()[0] == "* [#B] Updated heading" - - assert str(node).splitlines()[1:] == [" Body line", " Second line"] - - -def test_children_setter_reparents_root() -> None: - content = """* A -** A1 -* B -** B1""" - root = loads(content) - (a, b) = root.children - b1 = b.children[0] - - root.children = [b] - - assert root.children == [b] - assert b.parent is root - assert b1.parent is b - assert [node.heading for node in root[1:]] == ["B", "B1"] - assert a not in list(root) - - -def test_children_setter_reparents_and_adjusts_levels() -> None: - content = """* A -** A1 -*** A1a -* B -*** B1""" - root = loads(content) - a = root.children[0] - b = root.children[1] - b1 = b.children[0] - - a.children = [b1] - - assert a.children == [b1] - assert b.children == [] - assert b1.parent is a - assert b1.level == a.level + 1 - assert str(b1).splitlines()[0] == "** B1" - assert [node.heading for node in root[1:]] == ["A", "B1", "B"] - - -def test_children_setter_reparents_subtree_and_shifts_descendants() -> None: - content = """* A -** A1 -*** A1a -* B -** B1 -*** B1a""" - root = loads(content) - a1 = root.children[0].children[0] - b = root.children[1] - b1 = b.children[0] - b1a = b1.children[0] - - a1.children = [b1] - - assert a1.children == [b1] - assert b.children == [] - assert b1.parent is a1 - assert b1.level == a1.level + 1 - assert b1a.level == b1.level + 1 - assert str(b1).splitlines()[0] == "*** B1" - assert str(b1a).splitlines()[0] == "**** B1a" - assert "A1a" not in [node.heading for node in root[1:]] - - -def test_dynamic_timestamp_edits() -> None: - content = """* Node - CLOSED: [2012-02-26 Sun 21:15] SCHEDULED: <2012-02-26 Sun> - CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] => 0:05 - Body""" - root = loads(content) - node = root.children[0] - - node.deadline = datetime.date(2012, 3, 1) - sdc_line = str(node).splitlines()[1] - assert "DEADLINE: <2012-03-01 Thu>" in sdc_line - assert "SCHEDULED: <2012-02-26 Sun>" in sdc_line - assert "CLOSED: [2012-02-26 Sun 21:15]" in sdc_line - - node.closed = None - sdc_line = str(node).splitlines()[1] - assert "CLOSED:" not in sdc_line - - node.clock = [OrgDateClock((2012, 2, 26, 22, 0, 0), (2012, 2, 26, 22, 30, 0))] - clock_line = str(node).splitlines()[2] - assert "CLOCK: [2012-02-26 Sun 22:00]--[2012-02-26 Sun 22:30]" in clock_line - - -def test_add_scheduled_timestamp_line() -> None: - content = """* Node - Body""" - root = loads(content) - node = root.children[0] - - node.scheduled = datetime.date(2012, 2, 26) - assert str(node).splitlines()[:2] == ["* Node", "SCHEDULED: <2012-02-26 Sun>"] - assert str(node).splitlines()[2] == " Body" - - -def test_overwrite_existing_dates() -> None: - content = """* Node - SCHEDULED: <2012-02-26 Sun> DEADLINE: <2012-02-27 Mon> CLOSED: [2012-02-25 Sat] - Body""" - node = loads(content).children[0] - - node.scheduled = datetime.date(2012, 3, 1) - node.deadline = datetime.date(2012, 3, 2) - node.closed = datetime.datetime(2012, 3, 3, 10, 30) - - sdc_line = str(node).splitlines()[1] - assert "SCHEDULED: <2012-03-01 Thu>" in sdc_line - assert "DEADLINE: <2012-03-02 Fri>" in sdc_line - assert "CLOSED: [2012-03-03 Sat 10:30]" in sdc_line - - -def test_add_dates_to_node_without_dates() -> None: - content = """* Node - Body""" - node = loads(content).children[0] - - node.scheduled = datetime.date(2012, 2, 26) - node.deadline = datetime.date(2012, 3, 1) - node.closed = datetime.datetime(2012, 2, 27, 8, 30) - - lines = str(node).splitlines() - assert lines[0] == "* Node" - assert lines[1] == "SCHEDULED: <2012-02-26 Sun> DEADLINE: <2012-03-01 Thu> CLOSED: [2012-02-27 Mon 08:30]" - assert lines[2] == " Body" - - -def test_add_dates_to_node_with_existing_dates() -> None: - content = """* Node - SCHEDULED: <2012-02-26 Sun> - Body""" - node = loads(content).children[0] - - node.deadline = datetime.date(2012, 3, 1) - node.closed = datetime.datetime(2012, 2, 27, 8, 30) - - sdc_line = str(node).splitlines()[1] - assert "SCHEDULED: <2012-02-26 Sun>" in sdc_line - assert "DEADLINE: <2012-03-01 Thu>" in sdc_line - assert "CLOSED: [2012-02-27 Mon 08:30]" in sdc_line - - -def test_remove_dates_from_node_without_dates() -> None: - content = """* Node - Body""" - node = loads(content).children[0] - - node.scheduled = None - node.deadline = None - node.closed = None - - assert str(node) == content - - -def test_remove_dates_from_node_with_dates() -> None: - content = """* Node - SCHEDULED: <2012-02-26 Sun> DEADLINE: <2012-03-01 Thu> - Body""" - node = loads(content).children[0] - - node.scheduled = None - assert "SCHEDULED:" not in str(node).splitlines()[1] - - node.deadline = None - lines = str(node).splitlines() - assert lines == ["* Node", " Body"] - - -def test_duplicate_scheduled_dates() -> None: - content = """* Node - SCHEDULED: <2012-02-26 Sun> SCHEDULED: <2012-03-01 Thu> - Body""" - node = loads(content).children[0] - assert node.scheduled.start == datetime.date(2012, 3, 1) - - node.scheduled = datetime.date(2012, 4, 1) - sdc_line = str(node).splitlines()[1] - assert "SCHEDULED: <2012-02-26 Sun>" in sdc_line - assert "SCHEDULED: <2012-04-01 Sun>" in sdc_line - - -def test_multiple_clock_entries() -> None: - content = """* Node - CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] => 0:05 - CLOCK: [2012-02-26 Sun 22:00]--[2012-02-26 Sun 22:30] => 0:30 - Body""" - node = loads(content).children[0] - - node.clock = [ - OrgDateClock((2012, 2, 26, 23, 0, 0), (2012, 2, 26, 23, 30, 0)), - OrgDateClock((2012, 2, 27, 1, 0, 0), (2012, 2, 27, 1, 15, 0)), - ] - lines = str(node).splitlines() - assert "CLOCK: [2012-02-26 Sun 23:00]--[2012-02-26 Sun 23:30]" in lines[1] - assert "CLOCK: [2012-02-27 Mon 01:00]--[2012-02-27 Mon 01:15]" in lines[2] - - node.clock = [] - assert all("CLOCK:" not in line for line in str(node).splitlines()) - - -def test_setting_inactive_scheduled_date() -> None: - content = """* Node - Body""" - node = loads(content).children[0] - - node.scheduled = OrgDate((2012, 2, 26), active=False) - assert str(node).splitlines()[1] == "SCHEDULED: [2012-02-26 Sun]" - - -def test_overwrite_properties() -> None: - content = """* Node - :PROPERTIES: - :Owner: Jane - :Effort: 1:10 - :END: - Body""" - node = loads(content).children[0] - - node.properties = {"Owner": "Alex", "Effort": "0:30"} - lines = str(node).splitlines() - assert lines[1] == " :PROPERTIES:" - assert " :Owner: Alex" in lines - assert " :Effort: 0:30" in lines - assert lines[-1] == " Body" - assert node.properties["Effort"] == 30 - - -def test_add_properties_without_drawer() -> None: - content = """* Node - Body""" - node = loads(content).children[0] - - node.properties = {"Owner": "Alex"} - lines = str(node).splitlines() - assert lines[:3] == ["* Node", " :PROPERTIES:", " :Owner: Alex"] - assert lines[3] == " :END:" - assert lines[4] == " Body" - - -def test_add_properties_with_existing_drawer() -> None: - content = """* Node - :PROPERTIES: - :Owner: Jane - :END: - Body""" - node = loads(content).children[0] - - node.properties = {"Owner": "Jane", "Project": "Alpha"} - lines = str(node).splitlines() - assert " :Owner: Jane" in lines - assert " :Project: Alpha" in lines - - -def test_remove_properties_without_drawer() -> None: - content = """* Node - Body""" - node = loads(content).children[0] - - node.properties = {} - assert str(node) == content - - -def test_remove_properties_with_drawer() -> None: - content = """* Node - :PROPERTIES: - :Owner: Jane - :END: - Body""" - node = loads(content).children[0] - - node.properties = {} - assert ( - str(node) - == """* Node - Body""" - ) - - -def test_duplicate_properties_update_last() -> None: - content = """* Node - :PROPERTIES: - :Owner: Jane - :Owner: Jill - :END: - Body""" - node = loads(content).children[0] - assert node.properties["Owner"] == "Jill" - - node.properties = {"Owner": "Alex"} - lines = str(node).splitlines() - assert " :Owner: Jane" in lines - assert " :Owner: Alex" in lines - - -def test_properties_preserve_output_when_unchanged() -> None: - content = """* Node - :PROPERTIES: - :Owner: Jane - :END: - Body""" - node = loads(content).children[0] - assert str(node) == content - - -def test_root_node_properties() -> None: - content = """Intro - -:PROPERTIES: -:Title: Example -:END: - -* Node""" - root = loads(content) - root.properties = {"Title": "Updated"} - assert "Title: Updated" in str(root) - - -def test_overwrite_repeated_tasks_in_logbook() -> None: - content = """* Node - :LOGBOOK: - - State "DONE" from "TODO" [2005-09-01 Thu 16:10] - - State "DONE" from "TODO" [2005-08-01 Mon 19:44] - :END: - Body""" - node = loads(content).children[0] - node.repeated_tasks = [ - OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), "TODO", "DONE"), - OrgDateRepeatedTask((2005, 6, 1, 10, 0, 0), "TODO", "DONE"), - ] - lines = str(node).splitlines() - assert "[2005-07-01 Fri 17:27]" in lines[2] - assert "[2005-06-01 Wed 10:00]" in lines[3] - - -def test_add_repeated_tasks_without_logbook() -> None: - content = """* Node - Body""" - node = loads(content).children[0] - node.repeated_tasks = [ - OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), "TODO", "DONE"), - ] - lines = str(node).splitlines() - assert lines[:4] == [ - "* Node", - " :LOGBOOK:", - " - State \"DONE\" from \"TODO\" [2005-09-01 Thu 16:10]", - " :END:", - ] - assert lines[4] == " Body" - - -def test_add_repeated_tasks_with_logbook() -> None: - content = """* Node - :LOGBOOK: - CLOCK: [2012-10-26 Fri 16:01] - :END: - Body""" - node = loads(content).children[0] - node.repeated_tasks = [ - OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), "TODO", "DONE"), - ] - lines = str(node).splitlines() - assert " CLOCK: [2012-10-26 Fri 16:01]" in lines - assert " - State \"DONE\" from \"TODO\" [2005-09-01 Thu 16:10]" in lines - - -def test_remove_repeated_tasks_without_logbook() -> None: - content = """* Node - Body""" - node = loads(content).children[0] - node.repeated_tasks = [] - assert str(node) == content - - -def test_remove_repeated_tasks_with_logbook() -> None: - content = """* Node - :LOGBOOK: - - State "DONE" from "TODO" [2005-09-01 Thu 16:10] - :END: - Body""" - node = loads(content).children[0] - node.repeated_tasks = [] - lines = str(node).splitlines() - assert lines == ["* Node", " :LOGBOOK:", " :END:", " Body"] - - -def test_multiple_logbook_drawers_update_all() -> None: - content = """* Node - :LOGBOOK: - - State "DONE" from "TODO" [2005-09-01 Thu 16:10] - :END: - :LOGBOOK: - - State "DONE" from "TODO" [2005-08-01 Mon 19:44] - :END: - Body""" - node = loads(content).children[0] - node.repeated_tasks = [ - OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), "TODO", "DONE"), - OrgDateRepeatedTask((2005, 6, 1, 10, 0, 0), "TODO", "DONE"), - ] - rendered = str(node) - assert rendered.count("[2005-07-01 Fri 17:27]") == 1 - assert rendered.count("[2005-06-01 Wed 10:00]") == 1 - - -def test_external_repeated_tasks_update() -> None: - content = """* Node - - State "DONE" from "TODO" [2005-09-01 Thu 16:10] - Body""" - node = loads(content).children[0] - node.repeated_tasks = [OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), "TODO", "DONE")] - lines = str(node).splitlines() - assert lines[1] == " - State \"DONE\" from \"TODO\" [2005-07-01 Fri 17:27]" - assert lines[2] == " Body" - - -def test_remove_generated_logbook_when_cleared() -> None: - content = """* Node - Body""" - node = loads(content).children[0] - node.repeated_tasks = [OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), "TODO", "DONE")] - node.repeated_tasks = [] - assert str(node) == content - - -def test_body_setter_preserves_structure() -> None: - content = """* Node - SCHEDULED: <2020-01-01 Wed> - :PROPERTIES: - :Foo: bar - :END: - - State "DONE" from "TODO" [2020-01-02 Thu] - Body line 1 - Body line 2 - CLOCK: [2020-01-03 Fri 10:00]--[2020-01-03 Fri 11:00] => 1:00""" - node = loads(content).children[0] - assert str(node) == content - - node.body = "New body\nSecond line" - - expected = """* Node - SCHEDULED: <2020-01-01 Wed> - :PROPERTIES: - :Foo: bar - :END: - - State "DONE" from "TODO" [2020-01-02 Thu] -New body -Second line - CLOCK: [2020-01-03 Fri 10:00]--[2020-01-03 Fri 11:00] => 1:00""" - assert str(node) == expected - - -def test_body_setter_clears_body() -> None: - content = """* Node - Body line 1 - Body line 2""" - node = loads(content).children[0] - node.body = "" - assert node.body == "" - assert str(node) == "* Node" - - -def test_body_setter_updates_timestamps() -> None: - content = """* Node - Body with <2020-01-01 Wed> and [2020-01-02 Thu]""" - node = loads(content).children[0] - assert [str(date) for date in node.datelist] == ["<2020-01-01 Wed>", "[2020-01-02 Thu]"] - - node.body = "New <2020-02-03 Mon>" - assert [str(date) for date in node.datelist] == ["<2020-02-03 Mon>"] - - def test_root() -> None: root = loads( ''' diff --git a/src/orgparse/tests/test_node_mutations.py b/src/orgparse/tests/test_node_mutations.py new file mode 100644 index 0000000..d1419ac --- /dev/null +++ b/src/orgparse/tests/test_node_mutations.py @@ -0,0 +1,511 @@ +import datetime + +from orgparse.date import OrgDate, OrgDateClock, OrgDateRepeatedTask + +from .. import loads + + +def test_dynamic_heading_edits() -> None: + content = """* TODO [#A] Heading :tag1:tag2: + Body line + Second line""" + root = loads(content) + node = root.children[0] + assert str(node) == content + + node.todo = "DONE" + assert node.todo == "DONE" + assert str(node).splitlines()[0] == "* DONE [#A] Heading :tag1:tag2:" + + node.priority = None + assert node.priority is None + assert str(node).splitlines()[0] == "* DONE Heading :tag1:tag2:" + + node.heading = "Updated heading" + assert node.heading == "Updated heading" + assert str(node).splitlines()[0] == "* DONE Updated heading :tag1:tag2:" + + node.tags = ["x", "y"] + assert node.shallow_tags == {"x", "y"} + assert str(node).splitlines()[0] == "* DONE Updated heading :x:y:" + + node.todo = None + node.priority = "B" + node.tags = [] + assert str(node).splitlines()[0] == "* [#B] Updated heading" + + assert str(node).splitlines()[1:] == [" Body line", " Second line"] + + +def test_children_setter_reparents_root() -> None: + content = """* A +** A1 +* B +** B1""" + root = loads(content) + (a, b) = root.children + b1 = b.children[0] + + root.children = [b] + + assert root.children == [b] + assert b.parent is root + assert b1.parent is b + assert [node.heading for node in root[1:]] == ["B", "B1"] + assert a not in list(root) + + +def test_children_setter_reparents_and_adjusts_levels() -> None: + content = """* A +** A1 +*** A1a +* B +*** B1""" + root = loads(content) + a = root.children[0] + b = root.children[1] + b1 = b.children[0] + + a.children = [b1] + + assert a.children == [b1] + assert b.children == [] + assert b1.parent is a + assert b1.level == a.level + 1 + assert str(b1).splitlines()[0] == "** B1" + assert [node.heading for node in root[1:]] == ["A", "B1", "B"] + + +def test_children_setter_reparents_subtree_and_shifts_descendants() -> None: + content = """* A +** A1 +*** A1a +* B +** B1 +*** B1a""" + root = loads(content) + a1 = root.children[0].children[0] + b = root.children[1] + b1 = b.children[0] + b1a = b1.children[0] + + a1.children = [b1] + + assert a1.children == [b1] + assert b.children == [] + assert b1.parent is a1 + assert b1.level == a1.level + 1 + assert b1a.level == b1.level + 1 + assert str(b1).splitlines()[0] == "*** B1" + assert str(b1a).splitlines()[0] == "**** B1a" + assert "A1a" not in [node.heading for node in root[1:]] + + +def test_dynamic_timestamp_edits() -> None: + content = """* Node + CLOSED: [2012-02-26 Sun 21:15] SCHEDULED: <2012-02-26 Sun> + CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] => 0:05 + Body""" + root = loads(content) + node = root.children[0] + + node.deadline = datetime.date(2012, 3, 1) + sdc_line = str(node).splitlines()[1] + assert "DEADLINE: <2012-03-01 Thu>" in sdc_line + assert "SCHEDULED: <2012-02-26 Sun>" in sdc_line + assert "CLOSED: [2012-02-26 Sun 21:15]" in sdc_line + + node.closed = None + sdc_line = str(node).splitlines()[1] + assert "CLOSED:" not in sdc_line + + node.clock = [OrgDateClock((2012, 2, 26, 22, 0, 0), (2012, 2, 26, 22, 30, 0))] + clock_line = str(node).splitlines()[2] + assert "CLOCK: [2012-02-26 Sun 22:00]--[2012-02-26 Sun 22:30]" in clock_line + + +def test_add_scheduled_timestamp_line() -> None: + content = """* Node + Body""" + root = loads(content) + node = root.children[0] + + node.scheduled = datetime.date(2012, 2, 26) + assert str(node).splitlines()[:2] == ["* Node", "SCHEDULED: <2012-02-26 Sun>"] + assert str(node).splitlines()[2] == " Body" + + +def test_overwrite_existing_dates() -> None: + content = """* Node + SCHEDULED: <2012-02-26 Sun> DEADLINE: <2012-02-27 Mon> CLOSED: [2012-02-25 Sat] + Body""" + node = loads(content).children[0] + + node.scheduled = datetime.date(2012, 3, 1) + node.deadline = datetime.date(2012, 3, 2) + node.closed = datetime.datetime(2012, 3, 3, 10, 30) + + sdc_line = str(node).splitlines()[1] + assert "SCHEDULED: <2012-03-01 Thu>" in sdc_line + assert "DEADLINE: <2012-03-02 Fri>" in sdc_line + assert "CLOSED: [2012-03-03 Sat 10:30]" in sdc_line + + +def test_add_dates_to_node_without_dates() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + + node.scheduled = datetime.date(2012, 2, 26) + node.deadline = datetime.date(2012, 3, 1) + node.closed = datetime.datetime(2012, 2, 27, 8, 30) + + lines = str(node).splitlines() + assert lines[0] == "* Node" + assert lines[1] == "SCHEDULED: <2012-02-26 Sun> DEADLINE: <2012-03-01 Thu> CLOSED: [2012-02-27 Mon 08:30]" + assert lines[2] == " Body" + + +def test_add_dates_to_node_with_existing_dates() -> None: + content = """* Node + SCHEDULED: <2012-02-26 Sun> + Body""" + node = loads(content).children[0] + + node.deadline = datetime.date(2012, 3, 1) + node.closed = datetime.datetime(2012, 2, 27, 8, 30) + + sdc_line = str(node).splitlines()[1] + assert "SCHEDULED: <2012-02-26 Sun>" in sdc_line + assert "DEADLINE: <2012-03-01 Thu>" in sdc_line + assert "CLOSED: [2012-02-27 Mon 08:30]" in sdc_line + + +def test_remove_dates_from_node_without_dates() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + + node.scheduled = None + node.deadline = None + node.closed = None + + assert str(node) == content + + +def test_remove_dates_from_node_with_dates() -> None: + content = """* Node + SCHEDULED: <2012-02-26 Sun> DEADLINE: <2012-03-01 Thu> + Body""" + node = loads(content).children[0] + + node.scheduled = None + assert "SCHEDULED:" not in str(node).splitlines()[1] + + node.deadline = None + lines = str(node).splitlines() + assert lines == ["* Node", " Body"] + + +def test_duplicate_scheduled_dates() -> None: + content = """* Node + SCHEDULED: <2012-02-26 Sun> SCHEDULED: <2012-03-01 Thu> + Body""" + node = loads(content).children[0] + assert node.scheduled.start == datetime.date(2012, 3, 1) + + node.scheduled = datetime.date(2012, 4, 1) + sdc_line = str(node).splitlines()[1] + assert "SCHEDULED: <2012-02-26 Sun>" in sdc_line + assert "SCHEDULED: <2012-04-01 Sun>" in sdc_line + + +def test_multiple_clock_entries() -> None: + content = """* Node + CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] => 0:05 + CLOCK: [2012-02-26 Sun 22:00]--[2012-02-26 Sun 22:30] => 0:30 + Body""" + node = loads(content).children[0] + + node.clock = [ + OrgDateClock((2012, 2, 26, 23, 0, 0), (2012, 2, 26, 23, 30, 0)), + OrgDateClock((2012, 2, 27, 1, 0, 0), (2012, 2, 27, 1, 15, 0)), + ] + lines = str(node).splitlines() + assert "CLOCK: [2012-02-26 Sun 23:00]--[2012-02-26 Sun 23:30]" in lines[1] + assert "CLOCK: [2012-02-27 Mon 01:00]--[2012-02-27 Mon 01:15]" in lines[2] + + node.clock = [] + assert all("CLOCK:" not in line for line in str(node).splitlines()) + + +def test_setting_inactive_scheduled_date() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + + node.scheduled = OrgDate((2012, 2, 26), active=False) + assert str(node).splitlines()[1] == "SCHEDULED: [2012-02-26 Sun]" + + +def test_overwrite_properties() -> None: + content = """* Node + :PROPERTIES: + :Owner: Jane + :Effort: 1:10 + :END: + Body""" + node = loads(content).children[0] + + node.properties = {"Owner": "Alex", "Effort": "0:30"} + lines = str(node).splitlines() + assert lines[1] == " :PROPERTIES:" + assert " :Owner: Alex" in lines + assert " :Effort: 0:30" in lines + assert lines[-1] == " Body" + assert node.properties["Effort"] == 30 + + +def test_add_properties_without_drawer() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + + node.properties = {"Owner": "Alex"} + lines = str(node).splitlines() + assert lines[:3] == ["* Node", " :PROPERTIES:", " :Owner: Alex"] + assert lines[3] == " :END:" + assert lines[4] == " Body" + + +def test_add_properties_with_existing_drawer() -> None: + content = """* Node + :PROPERTIES: + :Owner: Jane + :END: + Body""" + node = loads(content).children[0] + + node.properties = {"Owner": "Jane", "Project": "Alpha"} + lines = str(node).splitlines() + assert " :Owner: Jane" in lines + assert " :Project: Alpha" in lines + + +def test_remove_properties_without_drawer() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + + node.properties = {} + assert str(node) == content + + +def test_remove_properties_with_drawer() -> None: + content = """* Node + :PROPERTIES: + :Owner: Jane + :END: + Body""" + node = loads(content).children[0] + + node.properties = {} + assert ( + str(node) + == """* Node + Body""" + ) + + +def test_duplicate_properties_update_last() -> None: + content = """* Node + :PROPERTIES: + :Owner: Jane + :Owner: Jill + :END: + Body""" + node = loads(content).children[0] + assert node.properties["Owner"] == "Jill" + + node.properties = {"Owner": "Alex"} + lines = str(node).splitlines() + assert " :Owner: Jane" in lines + assert " :Owner: Alex" in lines + + +def test_properties_preserve_output_when_unchanged() -> None: + content = """* Node + :PROPERTIES: + :Owner: Jane + :END: + Body""" + node = loads(content).children[0] + assert str(node) == content + + +def test_root_node_properties() -> None: + content = """Intro + +:PROPERTIES: +:Title: Example +:END: + +* Node""" + root = loads(content) + root.properties = {"Title": "Updated"} + assert "Title: Updated" in str(root) + + +def test_overwrite_repeated_tasks_in_logbook() -> None: + content = """* Node + :LOGBOOK: + - State "DONE" from "TODO" [2005-09-01 Thu 16:10] + - State "DONE" from "TODO" [2005-08-01 Mon 19:44] + :END: + Body""" + node = loads(content).children[0] + node.repeated_tasks = [ + OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), "TODO", "DONE"), + OrgDateRepeatedTask((2005, 6, 1, 10, 0, 0), "TODO", "DONE"), + ] + lines = str(node).splitlines() + assert "[2005-07-01 Fri 17:27]" in lines[2] + assert "[2005-06-01 Wed 10:00]" in lines[3] + + +def test_add_repeated_tasks_without_logbook() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + node.repeated_tasks = [ + OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), "TODO", "DONE"), + ] + lines = str(node).splitlines() + assert lines[:4] == [ + "* Node", + " :LOGBOOK:", + " - State \"DONE\" from \"TODO\" [2005-09-01 Thu 16:10]", + " :END:", + ] + assert lines[4] == " Body" + + +def test_add_repeated_tasks_with_logbook() -> None: + content = """* Node + :LOGBOOK: + CLOCK: [2012-10-26 Fri 16:01] + :END: + Body""" + node = loads(content).children[0] + node.repeated_tasks = [ + OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), "TODO", "DONE"), + ] + lines = str(node).splitlines() + assert " CLOCK: [2012-10-26 Fri 16:01]" in lines + assert " - State \"DONE\" from \"TODO\" [2005-09-01 Thu 16:10]" in lines + + +def test_remove_repeated_tasks_without_logbook() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + node.repeated_tasks = [] + assert str(node) == content + + +def test_remove_repeated_tasks_with_logbook() -> None: + content = """* Node + :LOGBOOK: + - State "DONE" from "TODO" [2005-09-01 Thu 16:10] + :END: + Body""" + node = loads(content).children[0] + node.repeated_tasks = [] + lines = str(node).splitlines() + assert lines == ["* Node", " :LOGBOOK:", " :END:", " Body"] + + +def test_multiple_logbook_drawers_update_all() -> None: + content = """* Node + :LOGBOOK: + - State "DONE" from "TODO" [2005-09-01 Thu 16:10] + :END: + :LOGBOOK: + - State "DONE" from "TODO" [2005-08-01 Mon 19:44] + :END: + Body""" + node = loads(content).children[0] + node.repeated_tasks = [ + OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), "TODO", "DONE"), + OrgDateRepeatedTask((2005, 6, 1, 10, 0, 0), "TODO", "DONE"), + ] + rendered = str(node) + assert rendered.count("[2005-07-01 Fri 17:27]") == 1 + assert rendered.count("[2005-06-01 Wed 10:00]") == 1 + + +def test_external_repeated_tasks_update() -> None: + content = """* Node + - State "DONE" from "TODO" [2005-09-01 Thu 16:10] + Body""" + node = loads(content).children[0] + node.repeated_tasks = [OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), "TODO", "DONE")] + lines = str(node).splitlines() + assert lines[1] == " - State \"DONE\" from \"TODO\" [2005-07-01 Fri 17:27]" + assert lines[2] == " Body" + + +def test_remove_generated_logbook_when_cleared() -> None: + content = """* Node + Body""" + node = loads(content).children[0] + node.repeated_tasks = [OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), "TODO", "DONE")] + node.repeated_tasks = [] + assert str(node) == content + + +def test_body_setter_preserves_structure() -> None: + content = """* Node + SCHEDULED: <2020-01-01 Wed> + :PROPERTIES: + :Foo: bar + :END: + - State "DONE" from "TODO" [2020-01-02 Thu] + Body line 1 + Body line 2 + CLOCK: [2020-01-03 Fri 10:00]--[2020-01-03 Fri 11:00] => 1:00""" + node = loads(content).children[0] + assert str(node) == content + + node.body = "New body\nSecond line" + + expected = """* Node + SCHEDULED: <2020-01-01 Wed> + :PROPERTIES: + :Foo: bar + :END: + - State "DONE" from "TODO" [2020-01-02 Thu] +New body +Second line + CLOCK: [2020-01-03 Fri 10:00]--[2020-01-03 Fri 11:00] => 1:00""" + assert str(node) == expected + + +def test_body_setter_clears_body() -> None: + content = """* Node + Body line 1 + Body line 2""" + node = loads(content).children[0] + node.body = "" + assert node.body == "" + assert str(node) == "* Node" + + +def test_body_setter_updates_timestamps() -> None: + content = """* Node + Body with <2020-01-01 Wed> and [2020-01-02 Thu]""" + node = loads(content).children[0] + assert [str(date) for date in node.datelist] == ["<2020-01-01 Wed>", "[2020-01-02 Thu]"] + + node.body = "New <2020-02-03 Mon>" + assert [str(date) for date in node.datelist] == ["<2020-02-03 Mon>"] From 6e821fdf165f8ed275b856af4ce9d2766a00dbca Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Mon, 23 Feb 2026 00:34:12 +0100 Subject: [PATCH 08/13] Add a way to store modified nodes in a file. --- src/orgparse/__init__.py | 35 ++++++++++++++++++-- src/orgparse/tests/test_dump.py | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 src/orgparse/tests/test_dump.py diff --git a/src/orgparse/__init__.py b/src/orgparse/__init__.py index 110c474..f038364 100644 --- a/src/orgparse/__init__.py +++ b/src/orgparse/__init__.py @@ -106,13 +106,16 @@ """ # [[[end]]] +from __future__ import annotations + from collections.abc import Iterable +from itertools import chain from pathlib import Path from typing import Optional, TextIO, Union -from .node import OrgEnv, OrgNode, parse_lines # todo basenode?? +from .node import OrgBaseNode, OrgEnv, OrgNode, parse_lines # todo basenode?? -__all__ = ["load", "loadi", "loads"] +__all__ = ["dump", "dumps", "load", "loadi", "loads"] def load(path: Union[str, Path, TextIO], env: Optional[OrgEnv] = None) -> OrgNode: @@ -145,6 +148,34 @@ def load(path: Union[str, Path, TextIO], env: Optional[OrgEnv] = None) -> OrgNod return loadi(all_lines, filename=filename, env=env) +def dumps(nodes: OrgBaseNode | Iterable[OrgBaseNode]) -> str: + """ + Dump org-mode nodes to a string. + + :arg nodes: Org root node or iterable of nodes to serialize. + :rtype: str + """ + if isinstance(nodes, OrgBaseNode): + if nodes.is_root(): + lines = chain.from_iterable(node._render_lines() for node in nodes.env.nodes) + return "\n".join(lines) + return str(nodes) + return "\n".join(str(node) for node in nodes) + + +def dump(nodes: OrgBaseNode | Iterable[OrgBaseNode], path: Union[str, Path]) -> None: + """ + Dump org-mode nodes to a file. + + :type path: str or Path + :arg path: Path to write org-mode contents. + """ + content = dumps(nodes) + if isinstance(path, str): + path = Path(path) + path.write_text(content, encoding="utf8") + + def loads(string: str, filename: str = '', env: Optional[OrgEnv] = None) -> OrgNode: """ Load org-mode document from a string. diff --git a/src/orgparse/tests/test_dump.py b/src/orgparse/tests/test_dump.py new file mode 100644 index 0000000..9b32aa6 --- /dev/null +++ b/src/orgparse/tests/test_dump.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from pathlib import Path + +from .. import dump, dumps, load, loads + + +def test_dump_roundtrip(tmp_path: Path) -> None: + content = """* Node + Body""" + source = tmp_path / "source.org" + target = tmp_path / "target.org" + source.write_text(content, encoding="utf8") + + root = load(source) + dump(root, target) + + assert target.read_text(encoding="utf8") == content + + +def test_dumps_iterable_nodes() -> None: + root = loads("""* A +* B""") + output = dumps(root.children) + assert ( + output + == """* A +* B""" + ) + + +def test_dump_reflects_updates() -> None: + root = loads("""* Node + Body""") + node = root.children[0] + node.heading = "Updated" + node.body = "New body" + + assert ( + dumps(root) + == """* Updated +New body""" + ) + + +def test_dump_multiple_nodes(tmp_path: Path) -> None: + root = loads("""* A +* B""") + target = tmp_path / "nodes.org" + + dump(root.children, target) + + assert ( + target.read_text(encoding="utf8") + == """* A +* B""" + ) From c031581c971542434f2fc54f3fb15ab347763bf6 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Mon, 23 Feb 2026 10:05:08 +0100 Subject: [PATCH 09/13] Cleaned up the tests a bit. --- src/orgparse/tests/test_node_mutations.py | 181 +++++++++++++++------- 1 file changed, 128 insertions(+), 53 deletions(-) diff --git a/src/orgparse/tests/test_node_mutations.py b/src/orgparse/tests/test_node_mutations.py index d1419ac..20b34f3 100644 --- a/src/orgparse/tests/test_node_mutations.py +++ b/src/orgparse/tests/test_node_mutations.py @@ -32,9 +32,13 @@ def test_dynamic_heading_edits() -> None: node.todo = None node.priority = "B" node.tags = [] - assert str(node).splitlines()[0] == "* [#B] Updated heading" - assert str(node).splitlines()[1:] == [" Body line", " Second line"] + assert ( + str(node) + == """* [#B] Updated heading + Body line + Second line""" + ) def test_children_setter_reparents_root() -> None: @@ -131,8 +135,12 @@ def test_add_scheduled_timestamp_line() -> None: node = root.children[0] node.scheduled = datetime.date(2012, 2, 26) - assert str(node).splitlines()[:2] == ["* Node", "SCHEDULED: <2012-02-26 Sun>"] - assert str(node).splitlines()[2] == " Body" + assert ( + str(node) + == """* Node +SCHEDULED: <2012-02-26 Sun> + Body""" + ) def test_overwrite_existing_dates() -> None: @@ -160,10 +168,12 @@ def test_add_dates_to_node_without_dates() -> None: node.deadline = datetime.date(2012, 3, 1) node.closed = datetime.datetime(2012, 2, 27, 8, 30) - lines = str(node).splitlines() - assert lines[0] == "* Node" - assert lines[1] == "SCHEDULED: <2012-02-26 Sun> DEADLINE: <2012-03-01 Thu> CLOSED: [2012-02-27 Mon 08:30]" - assert lines[2] == " Body" + assert ( + str(node) + == """* Node +SCHEDULED: <2012-02-26 Sun> DEADLINE: <2012-03-01 Thu> CLOSED: [2012-02-27 Mon 08:30] + Body""" + ) def test_add_dates_to_node_with_existing_dates() -> None: @@ -200,11 +210,13 @@ def test_remove_dates_from_node_with_dates() -> None: node = loads(content).children[0] node.scheduled = None - assert "SCHEDULED:" not in str(node).splitlines()[1] - node.deadline = None - lines = str(node).splitlines() - assert lines == ["* Node", " Body"] + + assert ( + str(node) + == """* Node + Body""" + ) def test_duplicate_scheduled_dates() -> None: @@ -231,12 +243,22 @@ def test_multiple_clock_entries() -> None: OrgDateClock((2012, 2, 26, 23, 0, 0), (2012, 2, 26, 23, 30, 0)), OrgDateClock((2012, 2, 27, 1, 0, 0), (2012, 2, 27, 1, 15, 0)), ] - lines = str(node).splitlines() - assert "CLOCK: [2012-02-26 Sun 23:00]--[2012-02-26 Sun 23:30]" in lines[1] - assert "CLOCK: [2012-02-27 Mon 01:00]--[2012-02-27 Mon 01:15]" in lines[2] + + assert ( + str(node) + == """* Node + CLOCK: [2012-02-26 Sun 23:00]--[2012-02-26 Sun 23:30] => 0:30 + CLOCK: [2012-02-27 Sun 01:00]--[2012-02-27 Sun 01:15] => 0:15 + Body""" + ) node.clock = [] - assert all("CLOCK:" not in line for line in str(node).splitlines()) + + assert ( + str(node) + == """* Node + Body""" + ) def test_setting_inactive_scheduled_date() -> None: @@ -245,7 +267,12 @@ def test_setting_inactive_scheduled_date() -> None: node = loads(content).children[0] node.scheduled = OrgDate((2012, 2, 26), active=False) - assert str(node).splitlines()[1] == "SCHEDULED: [2012-02-26 Sun]" + assert ( + str(node) + == """* Node +SCHEDULED: [2012-02-26 Sun] + Body""" + ) def test_overwrite_properties() -> None: @@ -258,12 +285,16 @@ def test_overwrite_properties() -> None: node = loads(content).children[0] node.properties = {"Owner": "Alex", "Effort": "0:30"} - lines = str(node).splitlines() - assert lines[1] == " :PROPERTIES:" - assert " :Owner: Alex" in lines - assert " :Effort: 0:30" in lines - assert lines[-1] == " Body" assert node.properties["Effort"] == 30 + assert ( + str(node) + == """* Node + :PROPERTIES: + :Owner: Alex + :Effort: 0:30 + :END: + Body""" + ) def test_add_properties_without_drawer() -> None: @@ -272,10 +303,14 @@ def test_add_properties_without_drawer() -> None: node = loads(content).children[0] node.properties = {"Owner": "Alex"} - lines = str(node).splitlines() - assert lines[:3] == ["* Node", " :PROPERTIES:", " :Owner: Alex"] - assert lines[3] == " :END:" - assert lines[4] == " Body" + assert ( + str(node) + == """* Node + :PROPERTIES: + :Owner: Alex + :END: + Body""" + ) def test_add_properties_with_existing_drawer() -> None: @@ -287,9 +322,15 @@ def test_add_properties_with_existing_drawer() -> None: node = loads(content).children[0] node.properties = {"Owner": "Jane", "Project": "Alpha"} - lines = str(node).splitlines() - assert " :Owner: Jane" in lines - assert " :Project: Alpha" in lines + assert ( + str(node) + == """* Node + :PROPERTIES: + :Owner: Jane + :Project: Alpha + :END: + Body""" + ) def test_remove_properties_without_drawer() -> None: @@ -328,9 +369,15 @@ def test_duplicate_properties_update_last() -> None: assert node.properties["Owner"] == "Jill" node.properties = {"Owner": "Alex"} - lines = str(node).splitlines() - assert " :Owner: Jane" in lines - assert " :Owner: Alex" in lines + assert ( + str(node) + == """* Node + :PROPERTIES: + :Owner: Jane + :Owner: Alex + :END: + Body""" + ) def test_properties_preserve_output_when_unchanged() -> None: @@ -353,7 +400,15 @@ def test_root_node_properties() -> None: * Node""" root = loads(content) root.properties = {"Title": "Updated"} - assert "Title: Updated" in str(root) + assert ( + str(root) + == """Intro + +:PROPERTIES: +:Title: Updated +:END: +""" + ) def test_overwrite_repeated_tasks_in_logbook() -> None: @@ -368,9 +423,15 @@ def test_overwrite_repeated_tasks_in_logbook() -> None: OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), "TODO", "DONE"), OrgDateRepeatedTask((2005, 6, 1, 10, 0, 0), "TODO", "DONE"), ] - lines = str(node).splitlines() - assert "[2005-07-01 Fri 17:27]" in lines[2] - assert "[2005-06-01 Wed 10:00]" in lines[3] + assert ( + str(node) + == """* Node + :LOGBOOK: + - State "DONE" from "TODO" [2005-07-01 Fri 17:27] + - State "DONE" from "TODO" [2005-06-01 Wed 10:00] + :END: + Body""" + ) def test_add_repeated_tasks_without_logbook() -> None: @@ -380,14 +441,14 @@ def test_add_repeated_tasks_without_logbook() -> None: node.repeated_tasks = [ OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), "TODO", "DONE"), ] - lines = str(node).splitlines() - assert lines[:4] == [ - "* Node", - " :LOGBOOK:", - " - State \"DONE\" from \"TODO\" [2005-09-01 Thu 16:10]", - " :END:", - ] - assert lines[4] == " Body" + assert ( + str(node) + == """* Node + :LOGBOOK: + - State "DONE" from "TODO" [2005-09-01 Thu 16:10] + :END: + Body""" + ) def test_add_repeated_tasks_with_logbook() -> None: @@ -400,9 +461,15 @@ def test_add_repeated_tasks_with_logbook() -> None: node.repeated_tasks = [ OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), "TODO", "DONE"), ] - lines = str(node).splitlines() - assert " CLOCK: [2012-10-26 Fri 16:01]" in lines - assert " - State \"DONE\" from \"TODO\" [2005-09-01 Thu 16:10]" in lines + assert ( + str(node) + == """* Node + :LOGBOOK: + CLOCK: [2012-10-26 Fri 16:01] + - State "DONE" from "TODO" [2005-09-01 Thu 16:10] + :END: + Body""" + ) def test_remove_repeated_tasks_without_logbook() -> None: @@ -421,8 +488,13 @@ def test_remove_repeated_tasks_with_logbook() -> None: Body""" node = loads(content).children[0] node.repeated_tasks = [] - lines = str(node).splitlines() - assert lines == ["* Node", " :LOGBOOK:", " :END:", " Body"] + assert ( + str(node) + == """* Node + :LOGBOOK: + :END: + Body""" + ) def test_multiple_logbook_drawers_update_all() -> None: @@ -450,9 +522,12 @@ def test_external_repeated_tasks_update() -> None: Body""" node = loads(content).children[0] node.repeated_tasks = [OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), "TODO", "DONE")] - lines = str(node).splitlines() - assert lines[1] == " - State \"DONE\" from \"TODO\" [2005-07-01 Fri 17:27]" - assert lines[2] == " Body" + assert ( + str(node) + == """* Node + - State "DONE" from "TODO" [2005-07-01 Fri 17:27] + Body""" + ) def test_remove_generated_logbook_when_cleared() -> None: @@ -498,7 +573,7 @@ def test_body_setter_clears_body() -> None: node = loads(content).children[0] node.body = "" assert node.body == "" - assert str(node) == "* Node" + assert str(node) == """* Node""" def test_body_setter_updates_timestamps() -> None: From 42cd656834cd14eedbdfdf47a63271eba0da4ec9 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Mon, 23 Feb 2026 10:43:37 +0100 Subject: [PATCH 10/13] Fix CLOCK duration render after mutation --- src/orgparse/lines.py | 19 ++++++++---- src/orgparse/node.py | 8 ++--- src/orgparse/tests/test_node_mutations.py | 36 +++++++++++++++++++++-- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/orgparse/lines.py b/src/orgparse/lines.py index 9168fba..d3b5628 100644 --- a/src/orgparse/lines.py +++ b/src/orgparse/lines.py @@ -470,11 +470,10 @@ def render(self) -> str: class ClockLine(LineItem): _label_re = re.compile(r"^(?!#)(?P\s*CLOCK:\s+)") - def __init__(self, raw: str, prefix: str, date: OrgDateClock, suffix: str) -> None: + def __init__(self, raw: str, prefix: str, date: OrgDateClock) -> None: self._raw = raw self._prefix = prefix self.date = date - self._suffix = suffix self._dirty = False @classmethod @@ -501,19 +500,27 @@ def from_line(cls, line: str) -> ClockLine | None: date = OrgDateClock.from_str(line) if not date: return None - (ts_start, ts_end) = span + (ts_start, _ts_end) = span prefix = line[:ts_start] - suffix = line[ts_end:] - return cls(line, prefix, date, suffix) + return cls(line, prefix, date) def update(self, date: OrgDateClock) -> None: self.date = date self._dirty = True + @classmethod + def _compute_suffix(cls, date: OrgDateClock) -> str: + if not date.has_end(): + return "" + minutes = int(date.duration.total_seconds() // 60) + hours, mins = divmod(minutes, 60) + return f" => {hours}:{mins:02d}" + def render(self) -> str: if not self._dirty: return self._raw - rendered = f"{self._prefix}{self.date}{self._suffix}" + suffix = self._compute_suffix(self.date) + rendered = f"{self._prefix}{self.date}{suffix}" self._raw = rendered self._dirty = False return rendered diff --git a/src/orgparse/node.py b/src/orgparse/node.py index beaf791..0f7f7c2 100644 --- a/src/orgparse/node.py +++ b/src/orgparse/node.py @@ -1330,12 +1330,8 @@ def _update_sdc_entry(self, label: str, date: OrgDate) -> None: def _format_clock_line(self, clock: OrgDateClock) -> ClockLine: prefix = " CLOCK: " - suffix = "" - if clock.has_end(): - minutes = int(clock.duration.total_seconds() // 60) - hours, mins = divmod(minutes, 60) - suffix = f" => {hours}:{mins:02d}" - return ClockLine(f"{prefix}{clock}{suffix}", prefix, clock, suffix) + suffix = ClockLine._compute_suffix(clock) + return ClockLine(f"{prefix}{clock}{suffix}", prefix, clock) def _sync_logbook_drawers_from_lines(self) -> None: self._logbook_drawers = [] diff --git a/src/orgparse/tests/test_node_mutations.py b/src/orgparse/tests/test_node_mutations.py index 20b34f3..157743c 100644 --- a/src/orgparse/tests/test_node_mutations.py +++ b/src/orgparse/tests/test_node_mutations.py @@ -247,8 +247,8 @@ def test_multiple_clock_entries() -> None: assert ( str(node) == """* Node - CLOCK: [2012-02-26 Sun 23:00]--[2012-02-26 Sun 23:30] => 0:30 - CLOCK: [2012-02-27 Sun 01:00]--[2012-02-27 Sun 01:15] => 0:15 + CLOCK: [2012-02-26 Sun 23:00]--[2012-02-26 Sun 23:30] => 0:30 + CLOCK: [2012-02-27 Mon 01:00]--[2012-02-27 Mon 01:15] => 0:15 Body""" ) @@ -261,6 +261,38 @@ def test_multiple_clock_entries() -> None: ) +def test_clock_updates_recompute_duration() -> None: + content = """* Node + CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] => 0:05 + Body""" + node = loads(content).children[0] + + node.clock = [OrgDateClock((2012, 2, 26, 21, 10, 0), (2012, 2, 26, 21, 40, 0))] + + assert ( + str(node) + == """* Node + CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:40] => 0:30 + Body""" + ) + + +def test_clock_updates_drop_duration_when_no_end() -> None: + content = """* Node + CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] => 0:05 + Body""" + node = loads(content).children[0] + + node.clock = [OrgDateClock((2012, 2, 26, 21, 10, 0))] + + assert ( + str(node) + == """* Node + CLOCK: [2012-02-26 Sun 21:10] + Body""" + ) + + def test_setting_inactive_scheduled_date() -> None: content = """* Node Body""" From 1b157886721ee9ad331af1a0a7b0be545fbb34b9 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Mon, 23 Feb 2026 13:09:04 +0100 Subject: [PATCH 11/13] Refactor the internal representation to rely solely on LineItems. --- src/orgparse/node.py | 319 +++++++++++--------------------- src/orgparse/tests/test_rich.py | 7 +- 2 files changed, 110 insertions(+), 216 deletions(-) diff --git a/src/orgparse/node.py b/src/orgparse/node.py index 0f7f7c2..a906dbe 100644 --- a/src/orgparse/node.py +++ b/src/orgparse/node.py @@ -5,18 +5,10 @@ from collections.abc import Iterable, Iterator, Sequence from typing import Any, Optional, TypeVar, cast -from .date import ( - OrgDate, - OrgDateClock, - OrgDateClosed, - OrgDateDeadline, - OrgDateRepeatedTask, - OrgDateScheduled, - parse_sdc, -) +from .date import OrgDate, OrgDateClock, OrgDateClosed, OrgDateDeadline, OrgDateRepeatedTask, OrgDateScheduled from .extra import Rich, to_rich_text from .inline import to_plain_text -from .lines import ( # noqa: F401 +from .lines import ( ClockLine, HeadingLine, LineItem, @@ -30,12 +22,6 @@ SdcLine, TextLine, parse_duration_to_minutes, - parse_duration_to_minutes_float, - parse_heading_level, - parse_heading_priority, - parse_heading_tags, - parse_heading_todos, - parse_property, ) @@ -319,10 +305,6 @@ def __init__(self, env: OrgEnv, index: int | None = None) -> None: # content self._line_items: list[LineItem] = [] - self._lines: list[str] = [] - self._lines_dirty = False - - self._properties: dict[str, PropertyValue] = {} self._property_drawer: PropertyDrawer | None = None self._timestamps: list[OrgDate] = [] @@ -691,7 +673,13 @@ def properties(self) -> dict[str, PropertyValue]: 'value' """ - return self._properties + drawer = self._property_drawer + if drawer is None: + return {} + props: dict[str, PropertyValue] = {} + for entry in drawer.entries: + props[entry.key] = entry.value + return props @properties.setter def properties(self, value: dict[str, PropertyValue] | None) -> None: @@ -702,8 +690,6 @@ def properties(self, value: dict[str, PropertyValue] | None) -> None: (norm_value, render_value) = self._normalize_property_value(key, prop_value) normalized[key] = norm_value render_values[key] = render_value - self._properties = normalized - if not normalized: if self._property_drawer is not None: start_index = self._line_items.index(self._property_drawer.start_line) @@ -711,7 +697,6 @@ def properties(self, value: dict[str, PropertyValue] | None) -> None: for index in range(end_index, start_index - 1, -1): self._remove_line_item(index) self._property_drawer = None - self._lines_dirty = True return drawer = self._property_drawer @@ -746,7 +731,6 @@ def properties(self, value: dict[str, PropertyValue] | None) -> None: ) self._insert_line_item(insert_index, entry) drawer.entries.append(entry) - self._lines_dirty = True def get_property(self, key, val=None) -> Optional[PropertyValue]: """ @@ -759,22 +743,21 @@ def get_property(self, key, val=None) -> Optional[PropertyValue]: Default value to return. """ - return self._properties.get(key, val) + return self.properties.get(key, val) # parser @classmethod def from_chunk(cls, env, lines): self = cls(env) - self._lines = list(lines) - self._line_items = [TextLine(line) for line in self._lines] + self._line_items = [TextLine(line) for line in lines] self._parse_comments() return self def _parse_comments(self): special_comments: dict[str, list[str]] = {} - for line in self._lines: - parsed = parse_comment(line) + for line_item in self._line_items: + parsed = parse_comment(line_item.render()) if parsed: (key, vals) = parsed key = key.upper() # case insensitive, so keep as uppercase @@ -838,24 +821,6 @@ def _sync_property_drawer_from_lines(self) -> None: return index += 1 - def _iparse_properties(self, ilines: Iterator[str]) -> Iterator[str]: - self._properties = {} - in_property_field = False - for line in ilines: - if in_property_field: - if line.find(":END:") >= 0: - break - else: - (key, val) = parse_property(line) - if key is not None and val is not None: - self._properties.update({key: val}) - elif line.find(":PROPERTIES:") >= 0: - in_property_field = True - else: - yield line - for line in ilines: - yield line - # misc @property @@ -924,7 +889,7 @@ def get_body(self, format: str = 'plain') -> str: # noqa: A002 See also: :meth:`get_heading`. """ - return self._get_text('\n'.join(self._body_lines), format) if self._lines else '' + return self._get_text("\n".join(self._body_lines), format) if self._body_lines else "" @property def body(self) -> str: @@ -952,7 +917,6 @@ def _replace_body_lines(self, new_lines: list[str]) -> None: for offset, line in enumerate(new_lines): self._insert_line_item(insert_at + offset, TextLine(line)) self._body_lines = list(new_lines) - self._lines_dirty = True self._refresh_timestamps_after_body_change() def _body_line_indices(self) -> list[int]: @@ -1106,25 +1070,16 @@ def __str__(self) -> str: return "\n".join(self._render_lines()) def _render_lines(self) -> list[str]: - if self._lines_dirty: - self._lines = [line.render() for line in self._line_items] - self._lines_dirty = False - return self._lines + return [line.render() for line in self._line_items] def _update_line_item(self, index: int, item: LineItem) -> None: self._line_items[index] = item - if self._lines: - self._lines[index] = item.render() - else: - self._lines_dirty = True def _insert_line_item(self, index: int, item: LineItem) -> None: self._line_items.insert(index, item) - self._lines_dirty = True def _remove_line_item(self, index: int) -> None: del self._line_items[index] - self._lines_dirty = True # todo hmm, not sure if it really belongs here and not to OrgRootNode? def get_file_property_list(self, property: str): # noqa: A002 @@ -1177,17 +1132,13 @@ def is_root(self) -> bool: def _parse_pre(self): """Call parsers which must be called before tree structuring""" - ilines: Iterator[str] = iter(self._lines) - ilines = self._iparse_properties(ilines) - ilines = self._iparse_timestamps(ilines) - self._body_lines = list(ilines) self._sync_property_drawer_from_lines() - - def _iparse_timestamps(self, ilines: Iterator[str]) -> Iterator[str]: + self._body_lines = [ + item.render() for index, item in enumerate(self._line_items) if self._is_body_line_item(index, item) + ] self._timestamps = [] - for line in ilines: + for line in self._body_lines: self._timestamps.extend(OrgDate.list_from_str(line)) - yield line def _property_drawer_indent(self, insert_at: int) -> str: # noqa: ARG002 return "" @@ -1204,18 +1155,10 @@ class OrgNode(OrgBaseNode): def __init__(self, *args, **kwds) -> None: super().__init__(*args, **kwds) # fixme instead of casts, should organize code in such a way that they aren't necessary - self._heading = cast(str, None) self._level: int | None = None - self._tags = cast(list[str], None) - self._todo: Optional[str] = None - self._priority: Optional[str] = None self._heading_line: HeadingLine | None = None self._sdc_line: SdcLine | None = None self._clock_lines: list[ClockLine] = [] - self._scheduled = OrgDateScheduled(None) - self._deadline = OrgDateDeadline(None) - self._closed = OrgDateClosed(None) - self._clocklist: list[OrgDateClock] = [] self._body_lines: list[str] = [] self._repeated_tasks: list[OrgDateRepeatedTask] = [] self._logbook_drawers: list[LogbookDrawer] = [] @@ -1225,30 +1168,19 @@ def __init__(self, *args, **kwds) -> None: def _parse_pre(self): """Call parsers which must be called before tree structuring""" self._parse_heading() - # FIXME: make the following parsers "lazy" - ilines: Iterator[str] = iter(self._lines) - try: - next(ilines) # skip heading - except StopIteration: - return - self._clock_line_indices = iter(self._find_clock_line_indices()) - ilines = self._iparse_sdc(ilines) - ilines = self._iparse_clock(ilines) - ilines = self._iparse_properties(ilines) - ilines = self._iparse_repeated_tasks(ilines) - ilines = self._iparse_timestamps(ilines) - self._body_lines = list(ilines) + self._sync_sdc_line_from_items() + self._sync_clock_lines_from_items() self._sync_property_drawer_from_lines() self._sync_logbook_drawers_from_lines() + self._sync_repeated_tasks_cache() + self._refresh_body_and_timestamps() def _parse_heading(self) -> None: - heading_line = HeadingLine.from_line(self._lines[0], self.env.all_todo_keys) + if not self._line_items: + raise ValueError("OrgNode has no lines to parse heading") + heading_line = HeadingLine.from_line(self._line_items[0].render(), self.env.all_todo_keys) self._heading_line = heading_line self._level = heading_line.level - self._tags = list(heading_line.tags) - self._todo = heading_line.todo - self._priority = heading_line.priority - self._heading = heading_line.heading self._update_line_item(0, heading_line) def _normalize_tags(self, tags: Iterable[str] | None) -> list[str]: @@ -1285,26 +1217,48 @@ def _is_body_line_item(self, index: int, item: LineItem) -> bool: # noqa: ARG00 def _refresh_timestamps_after_body_change(self) -> None: self._timestamps = [] - self._timestamps.extend(OrgDate.list_from_str(self._heading)) + if self._heading_line is not None: + self._timestamps.extend(OrgDate.list_from_str(self._heading_line.heading)) for line in self._body_lines: self._timestamps.extend(OrgDate.list_from_str(line)) def _update_heading_line(self) -> None: if self._heading_line is None: return - self._heading_line.todo = self._todo - self._heading_line.priority = self._priority - self._heading_line.heading = self._heading - self._heading_line.tags = list(self._tags) self._heading_line.mark_dirty() self._update_line_item(0, self._heading_line) - def _find_clock_line_indices(self) -> list[int]: - indices: list[int] = [] - for index, line in enumerate(self._lines): - if OrgDateClock.from_str(line): - indices.append(index) - return indices + def _sync_sdc_line_from_items(self) -> None: + self._sdc_line = None + if len(self._line_items) < 2: + return + line_item = self._line_items[1] + sdc_line = SdcLine.from_line(line_item.render()) + if sdc_line is not None: + self._sdc_line = sdc_line + self._update_line_item(1, sdc_line) + + def _sync_clock_lines_from_items(self) -> None: + self._clock_lines = [] + for index, item in enumerate(self._line_items): + clock_line = ClockLine.from_line(item.render()) + if clock_line is None: + continue + self._clock_lines.append(clock_line) + self._update_line_item(index, clock_line) + + def _sync_repeated_tasks_cache(self) -> None: + self._repeated_tasks = [line.repeat for line in self._repeat_task_lines_in_order()] + + def _refresh_body_and_timestamps(self) -> None: + self._body_lines = [ + item.render() for index, item in enumerate(self._line_items) if self._is_body_line_item(index, item) + ] + self._timestamps = [] + if self._heading_line is not None: + self._timestamps.extend(OrgDate.list_from_str(self._heading_line.heading)) + for line in self._body_lines: + self._timestamps.extend(OrgDate.list_from_str(line)) def _coerce_sdc_date(self, value: Any, cls: type[TOrgDate]) -> TOrgDate: if value is None: @@ -1325,8 +1279,7 @@ def _update_sdc_entry(self, label: str, date: OrgDate) -> None: index = self._line_items.index(self._sdc_line) self._remove_line_item(index) self._sdc_line = None - else: - self._lines_dirty = True + return def _format_clock_line(self, clock: OrgDateClock) -> ClockLine: prefix = " CLOCK: " @@ -1424,94 +1377,6 @@ def _property_drawer_indent(self, insert_at: int) -> str: return before[: len(before) - len(before.lstrip(" "))] or " " return " " - # The following ``_iparse_*`` methods are simple generator based - # parser. See ``_parse_pre`` for how it is used. The principle - # is simple: these methods get an iterator and returns an iterator. - # If the item returned by the input iterator must be dedicated to - # the parser, do not yield the item or yield it as-is otherwise. - - def _iparse_sdc(self, ilines: Iterator[str]) -> Iterator[str]: - """ - Parse SCHEDULED, DEADLINE and CLOSED time tamps. - - They are assumed be in the first line. - - """ - try: - line = next(ilines) - except StopIteration: - return - sdc_line = SdcLine.from_line(line) - if sdc_line is not None: - self._sdc_line = sdc_line - self._update_line_item(1, sdc_line) - scheduled_entry = sdc_line._entries.get("SCHEDULED") - deadline_entry = sdc_line._entries.get("DEADLINE") - closed_entry = sdc_line._entries.get("CLOSED") - self._scheduled = ( - cast(OrgDateScheduled, scheduled_entry.date) if scheduled_entry is not None else OrgDateScheduled(None) - ) - self._deadline = ( - cast(OrgDateDeadline, deadline_entry.date) if deadline_entry is not None else OrgDateDeadline(None) - ) - self._closed = cast(OrgDateClosed, closed_entry.date) if closed_entry is not None else OrgDateClosed(None) - else: - (self._scheduled, self._deadline, self._closed) = parse_sdc(line) - if not (self._scheduled or self._deadline or self._closed): - yield line # when none of them were found - - for line in ilines: - yield line - - def _iparse_clock(self, ilines: Iterator[str]) -> Iterator[str]: - self._clocklist = [] - self._clock_lines = [] - for line in ilines: - cl = OrgDateClock.from_str(line) - if cl: - self._clocklist.append(cl) - clock_line = ClockLine.from_line(line) - if clock_line is not None: - self._clock_lines.append(clock_line) - index = next(self._clock_line_indices, None) - if index is not None: - self._update_line_item(index, clock_line) - else: - yield line - - def _iparse_timestamps(self, ilines: Iterator[str]) -> Iterator[str]: - self._timestamps = [] - self._timestamps.extend(OrgDate.list_from_str(self._heading)) - for l in ilines: - self._timestamps.extend(OrgDate.list_from_str(l)) - yield l - - def _iparse_repeated_tasks(self, ilines: Iterator[str]) -> Iterator[str]: - self._repeated_tasks = [] - for line in ilines: - if line.lstrip().startswith("#"): - yield line - continue - match = self._repeated_tasks_re.search(line) - if match: - # FIXME: move this parsing to OrgDateRepeatedTask.from_str - mdict = match.groupdict() - done_state = mdict['done'] - todo_state = mdict['todo'] - date = OrgDate.from_str(mdict['date']) - self._repeated_tasks.append(OrgDateRepeatedTask(date.start, todo_state, done_state)) - else: - yield line - - _repeated_tasks_re = re.compile( - r''' - \s*- \s+ - State \s+ "(?P [^"]+)" \s+ - from \s+ "(?P [^"]+)" \s+ - \[ (?P [^\]]+) \]''', - re.VERBOSE, - ) - def get_heading(self, format: str = 'plain') -> str: # noqa: A002 """ Return a string of head text without tags and TODO keywords. @@ -1532,7 +1397,10 @@ def get_heading(self, format: str = 'plain') -> str: # noqa: A002 '[[link][Node 1]]' """ - return self._get_text(self._heading, format) + heading = "" + if self._heading_line is not None: + heading = self._heading_line.heading + return self._get_text(heading, format) @property def heading(self) -> str: @@ -1541,7 +1409,9 @@ def heading(self) -> str: @heading.setter def heading(self, value: str) -> None: - self._heading = value + if self._heading_line is None: + return + self._heading_line.heading = value self._update_heading_line() @property @@ -1581,17 +1451,23 @@ def priority(self) -> str | None: True """ - return self._priority + if self._heading_line is None: + return None + return self._heading_line.priority @priority.setter def priority(self, value: str | None) -> None: if value == "": value = None - self._priority = value + if self._heading_line is None: + return + self._heading_line.priority = value self._update_heading_line() def _get_tags(self, *, inher: bool = False) -> set[str]: - tags = set(self._tags) + tags = set() + if self._heading_line is not None: + tags = set(self._heading_line.tags) if inher: parent = self.get_parent() if parent: @@ -1609,13 +1485,17 @@ def todo(self) -> Optional[str]: 'TODO' """ - return self._todo + if self._heading_line is None: + return None + return self._heading_line.todo @todo.setter def todo(self, value: Optional[str]) -> None: if value == "": value = None - self._todo = value + if self._heading_line is None: + return + self._heading_line.todo = value self._update_heading_line() @property @@ -1624,7 +1504,9 @@ def tags(self) -> set[str]: @tags.setter def tags(self, value: Iterable[str] | None) -> None: - self._tags = self._normalize_tags(value) + if self._heading_line is None: + return + self._heading_line.tags = self._normalize_tags(value) self._update_heading_line() @property @@ -1643,12 +1525,16 @@ def scheduled(self): OrgDateScheduled((2012, 2, 26)) """ - return self._scheduled + if self._sdc_line is None: + return OrgDateScheduled(None) + entry = self._sdc_line._entries.get("SCHEDULED") + if entry is None: + return OrgDateScheduled(None) + return cast(OrgDateScheduled, entry.date) @scheduled.setter def scheduled(self, value: Any) -> None: date = self._coerce_sdc_date(value, OrgDateScheduled) - self._scheduled = date self._update_sdc_entry("SCHEDULED", date) @property @@ -1667,12 +1553,16 @@ def deadline(self): OrgDateDeadline((2012, 2, 26)) """ - return self._deadline + if self._sdc_line is None: + return OrgDateDeadline(None) + entry = self._sdc_line._entries.get("DEADLINE") + if entry is None: + return OrgDateDeadline(None) + return cast(OrgDateDeadline, entry.date) @deadline.setter def deadline(self, value: Any) -> None: date = self._coerce_sdc_date(value, OrgDateDeadline) - self._deadline = date self._update_sdc_entry("DEADLINE", date) @property @@ -1691,12 +1581,16 @@ def closed(self): OrgDateClosed((2012, 2, 26, 21, 15, 0)) """ - return self._closed + if self._sdc_line is None: + return OrgDateClosed(None) + entry = self._sdc_line._entries.get("CLOSED") + if entry is None: + return OrgDateClosed(None) + return cast(OrgDateClosed, entry.date) @closed.setter def closed(self, value: Any) -> None: date = self._coerce_sdc_date(value, OrgDateClosed) - self._closed = date self._update_sdc_entry("CLOSED", date) @property @@ -1715,12 +1609,11 @@ def clock(self): [OrgDateClock((2012, 2, 26, 21, 10, 0), (2012, 2, 26, 21, 15, 0))] """ - return self._clocklist + return [line.date for line in self._clock_lines] @clock.setter def clock(self, value: Iterable[OrgDateClock]) -> None: new_clocks = list(value) - self._clocklist = new_clocks existing_indices = [i for i, item in enumerate(self._line_items) if isinstance(item, ClockLine)] if existing_indices: for i, clock in enumerate(new_clocks): @@ -1741,7 +1634,7 @@ def clock(self, value: Iterable[OrgDateClock]) -> None: insert_at = self._line_items.index(self._sdc_line) + 1 for i, clock in enumerate(new_clocks): self._insert_line_item(insert_at + i, self._format_clock_line(clock)) - self._lines_dirty = True + self._clock_lines = [item for item in self._line_items if isinstance(item, ClockLine)] def has_date(self): """ @@ -1823,7 +1716,7 @@ def repeated_tasks(self, value: Iterable[OrgDateRepeatedTask]) -> None: existing_lines.append(entry) self._remove_empty_generated_logbooks() - self._lines_dirty = True + self._sync_repeated_tasks_cache() def _repeat_task_insert_target( self, diff --git a/src/orgparse/tests/test_rich.py b/src/orgparse/tests/test_rich.py index 5171bb0..761f441 100644 --- a/src/orgparse/tests/test_rich.py +++ b/src/orgparse/tests/test_rich.py @@ -39,9 +39,10 @@ def test_table() -> None: [_gap1, t1, _gap2, t2, _gap3, t3, _gap4] = root.body_rich - t1 = Table(root._lines[1:10]) - t2 = Table(root._lines[11:19]) - t3 = Table(root._lines[22:26]) + rendered = root._render_lines() + t1 = Table(rendered[1:10]) + t2 = Table(rendered[11:19]) + t3 = Table(rendered[22:26]) assert ilen(t1.blocks) == 4 assert list(t1.blocks)[2] == [] From 72b6d9857f794ba578d164675d272ec65107c151 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Mon, 23 Feb 2026 14:26:43 +0100 Subject: [PATCH 12/13] Handle special blocks without parsing inner node-like structures. --- src/orgparse/node.py | 2 +- src/orgparse/tests/test_misc.py | 60 +++++++++++++++++++++++++-------- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/orgparse/node.py b/src/orgparse/node.py index a906dbe..5d9d0ec 100644 --- a/src/orgparse/node.py +++ b/src/orgparse/node.py @@ -35,7 +35,7 @@ def lines_to_chunks(lines: Iterable[str]) -> Iterable[list[str]]: yield chunk -RE_NODE_HEADER = re.compile(r"^\s*\*+ ") +RE_NODE_HEADER = re.compile(r"^\*+ ") TOrgDate = TypeVar("TOrgDate", bound=OrgDate) diff --git a/src/orgparse/tests/test_misc.py b/src/orgparse/tests/test_misc.py index b849d97..2038888 100644 --- a/src/orgparse/tests/test_misc.py +++ b/src/orgparse/tests/test_misc.py @@ -10,10 +10,10 @@ def test_empty_heading() -> None: root = loads(''' - * TODO :sometag: - has no heading but still a todo? - it's a bit unclear, but seems to be highligted by emacs.. - ''') +* TODO :sometag: + has no heading but still a todo? + it's a bit unclear, but seems to be highligted by emacs.. +''') [h] = root.children assert h.todo == 'TODO' assert h.heading == '' @@ -69,6 +69,38 @@ def test_stars(): assert h2.heading == '' +def test_stars_inside_blocks() -> None: + root = loads(''' +* Node 1 +#+begin_src c + /* + * Comment + */ +#+end_src +#+begin_example + * Example line +#+end_example +* Node 2 +''') + assert len(root.children) == 2 + body = root.children[0].get_body(format='raw') + assert '* Comment' in body + assert '* Example line' in body + + +def test_stars_inside_list() -> None: + root = loads(''' +* Node 1 + - List item + + Nested List item + * Another nested list item +* Node 2 +''') + assert len(root.children) == 2 + body = root.children[0].get_body(format='raw') + assert '* Another nested list item' in body + + def test_parse_custom_todo_keys(): todo_keys = ['TODO', 'CUSTOM1', 'ANOTHER_KEYWORD'] done_keys = ['DONE', 'A'] @@ -117,11 +149,11 @@ def test_add_custom_todo_keys(): def test_get_file_property() -> None: content = """#+TITLE: Test: title - * Node 1 - test 1 - * Node 2 - test 2 - """ +* Node 1 +test 1 +* Node 2 +test 2 +""" # after parsing, all keys are set root = loads(content) @@ -137,11 +169,11 @@ def test_get_file_property_multivalued() -> None: #+OTHER: Test title #+title: alternate title - * Node 1 - test 1 - * Node 2 - test 2 - """ +* Node 1 +test 1 +* Node 2 +test 2 +""" # after parsing, all keys are set root = loads(content) From ce68784c787e46ee25d440f12866a4d0fc8d46e0 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Mon, 23 Feb 2026 17:29:41 +0100 Subject: [PATCH 13/13] Handle repeated tasks with comments --- src/orgparse/date.py | 22 ++++++++++---- src/orgparse/lines.py | 30 ++++++++++++++++--- src/orgparse/node.py | 43 ++++++++++++++++++++++++--- src/orgparse/tests/test_misc.py | 51 +++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 13 deletions(-) diff --git a/src/orgparse/date.py b/src/orgparse/date.py index 1685f32..cc89ae8 100644 --- a/src/orgparse/date.py +++ b/src/orgparse/date.py @@ -692,19 +692,26 @@ class OrgDateRepeatedTask(OrgDate): _active_default = False - def __init__(self, start, before: str, after: str, active=None) -> None: + def __init__(self, start, before: str, after: str, active=None, *, comment: str | None = None) -> None: super().__init__(start, active=active) self._before = before self._after = after + self._comment = comment def __repr__(self) -> str: - args: list = [self._date_to_tuple(self.start), self.before, self.after] + args: list[str] = [ + repr(self._date_to_tuple(self.start)), + repr(self.before), + repr(self.after), + ] if self._active is not self._active_default: - args.append(self._active) - return '{}({})'.format(self.__class__.__name__, ', '.join(map(repr, args))) + args.append(repr(self._active)) + if self._comment is not None: + args.append(f"comment={self._comment!r}") + return "{}({})".format(self.__class__.__name__, ", ".join(args)) def __hash__(self) -> int: - return hash((self._before, self._after)) + return hash((self._before, self._after, self._comment)) def __eq__(self, other) -> bool: return ( @@ -712,6 +719,7 @@ def __eq__(self, other) -> bool: and isinstance(other, self.__class__) and self._before == other._before and self._after == other._after + and self._comment == other._comment ) @property @@ -737,3 +745,7 @@ def after(self) -> str: """ return self._after + + @property + def comment(self) -> str | None: + return self._comment diff --git a/src/orgparse/lines.py b/src/orgparse/lines.py index d3b5628..598754d 100644 --- a/src/orgparse/lines.py +++ b/src/orgparse/lines.py @@ -142,7 +142,7 @@ def parse_property(line: str) -> tuple[Optional[str], Optional[PropertyValue]]: RE_PROP = re.compile(r"^\s*:(.*?):\s*(.*?)\s*$") RE_PROP_LINE = re.compile(r"^(?P\s*):(?P[^:]+):\s*(?P.*?)\s*$") RE_REPEAT_TASK_LINE = re.compile( - r"^(?P\s*)-\s+State\s+\"(?P[^\"]+)\"\s+from\s+\"(?P[^\"]+)\"\s+\[(?P[^\]]+)\]\s*$" + r"^(?P\s*)-\s+State\s+\"(?P[^\"]+)\"\s+from\s+\"(?P[^\"]+)\"\s+\[(?P[^\]]+)\](?:\s*\\\\(?P.*))?\s*$" ) @@ -548,35 +548,53 @@ def __init__( raw: str, repeat: OrgDateRepeatedTask, indent: str, + comment: str | None, ) -> None: self._raw = raw self.repeat = repeat self.indent = indent + self.comment = comment self._dirty = False @classmethod - def from_line(cls, line: str) -> RepeatTaskLine | None: + def match(cls, line: str) -> re.Match[str] | None: + if line.lstrip().startswith("#"): + return None + return RE_REPEAT_TASK_LINE.match(line) + + @classmethod + def from_line(cls, line: str, comment: str | None = None) -> RepeatTaskLine | None: if line.lstrip().startswith("#"): return None match = RE_REPEAT_TASK_LINE.match(line) if not match: return None + inline_comment = match.group("comment") + if comment is None and inline_comment is not None: + inline_comment = inline_comment.lstrip() + comment = inline_comment date = OrgDate.from_str(match.group("date")) - repeat = OrgDateRepeatedTask(date.start, match.group("todo"), match.group("done")) + repeat = OrgDateRepeatedTask(date.start, match.group("todo"), match.group("done"), comment=comment) return cls( raw=line, repeat=repeat, indent=match.group("indent"), + comment=comment, ) @classmethod def from_repeat(cls, repeat: OrgDateRepeatedTask, indent: str) -> RepeatTaskLine: date_str = str(repeat) raw = f"{indent}- State \"{repeat.after}\" from \"{repeat.before}\" {date_str}" - return cls(raw=raw, repeat=repeat, indent=indent) + if repeat.comment is not None: + raw = f"{raw} \\\\" # comment lines are handled separately + if repeat.comment and "\n" not in repeat.comment: + raw = f"{raw} {repeat.comment}" + return cls(raw=raw, repeat=repeat, indent=indent, comment=repeat.comment) def update_repeat(self, repeat: OrgDateRepeatedTask) -> None: self.repeat = repeat + self.comment = repeat.comment self._dirty = True def render(self) -> str: @@ -584,6 +602,10 @@ def render(self) -> str: return self._raw date_str = str(self.repeat) rendered = f"{self.indent}- State \"{self.repeat.after}\" from \"{self.repeat.before}\" {date_str}" + if self.comment is not None: + rendered = f"{rendered} \\\\" # comment lines are handled separately + if self.comment and "\n" not in self.comment: + rendered = f"{rendered} {self.comment}" self._raw = rendered self._dirty = False return rendered diff --git a/src/orgparse/node.py b/src/orgparse/node.py index 5d9d0ec..4ba8994 100644 --- a/src/orgparse/node.py +++ b/src/orgparse/node.py @@ -1293,6 +1293,41 @@ def _sync_logbook_drawers_from_lines(self) -> None: entries: list[RepeatTaskLine] = [] indent = "" index = 0 + + def parse_repeat_with_comment(start_index: int) -> tuple[RepeatTaskLine | None, int]: + line_item = self._line_items[start_index] + line = line_item.render() + match = RepeatTaskLine.match(line) + if match is None: + return (None, start_index + 1) + inline_comment = match.group("comment") + comment_marker = inline_comment is not None + comment_lines: list[str] = [] + if inline_comment is not None: + inline_comment = inline_comment.lstrip() + if inline_comment: + comment_lines.append(inline_comment) + next_index = start_index + 1 + if comment_marker: + base_indent_len = len(match.group("indent")) + while next_index < len(self._line_items): + next_line = self._line_items[next_index].render() + if next_line.strip().upper() in (":END:", ":LOGBOOK:"): + break + if RepeatTaskLine.match(next_line) is not None: + break + next_indent = re.match(r"\s*", next_line) + assert next_indent is not None + if len(next_indent.group(0)) <= base_indent_len: + break + comment_lines.append(next_line.lstrip()) + next_index += 1 + comment: str | None = None + if comment_marker: + comment = "\n".join(comment_lines) + entry = RepeatTaskLine.from_line(line, comment=comment) + return (entry, next_index if comment_marker else start_index + 1) + while index < len(self._line_items): line_item = self._line_items[index] line = line_item.render() @@ -1316,16 +1351,16 @@ def _sync_logbook_drawers_from_lines(self) -> None: index += 1 continue if in_logbook: - entry = RepeatTaskLine.from_line(line) + entry, next_index = parse_repeat_with_comment(index) if entry is not None: self._update_line_item(index, entry) entries.append(entry) - index += 1 + index = next_index continue - entry = RepeatTaskLine.from_line(line) + entry, next_index = parse_repeat_with_comment(index) if entry is not None: self._update_line_item(index, entry) - index += 1 + index = next_index def _repeat_task_lines_in_order(self) -> list[RepeatTaskLine]: return [item for item in self._line_items if isinstance(item, RepeatTaskLine)] diff --git a/src/orgparse/tests/test_misc.py b/src/orgparse/tests/test_misc.py index 2038888..3c24204 100644 --- a/src/orgparse/tests/test_misc.py +++ b/src/orgparse/tests/test_misc.py @@ -101,6 +101,57 @@ def test_stars_inside_list() -> None: assert '* Another nested list item' in body +def test_repeated_tasks_with_comments() -> None: + root = loads(''' +* Node 1 +** TODO Test node + SCHEDULED: <2026-01-10 Sat +1m> + :LOGBOOK: + - State "DONE" from "TODO" [2026-01-02 Fri 17:04] + - State "CANCELLED" from "TODO" [2025-12-07 nie 17:15] + - State "CANCELLED" from "TODO" [2025-12-07 nie 17:14] \\\\ + Comment. + - State "CANCELLED" from "TODO" [2025-09-21 nie 16:09] \\\\ + Another comment. + - State "CANCELLED" from "TODO" [2025-09-06 sob 14:10] \\\\ + One more. + - State "CANCELLED" from "TODO" [2025-07-28 pon 18:36] + - State "CANCELLED" from "TODO" [2025-06-28 sob 15:34] \\\\ + Should be 7 repeats + :END: +''') + node = root.children[0].children[0] + assert len(node.repeated_tasks) == 7 + assert [repeat.comment for repeat in node.repeated_tasks] == [ + None, + None, + "Comment.", + "Another comment.", + "One more.", + None, + "Should be 7 repeats", + ] + + +def test_repeated_tasks_mixed_comments() -> None: + root = loads(''' +* Node 1 +** TODO Test node + :LOGBOOK: + - State "DONE" from "TODO" [2026-01-02 Fri 17:04] \\\\ + First line + Second line + - State "DONE" from "TODO" [2026-01-03 Fri 17:04] + :END: +''') + node = root.children[0].children[0] + assert len(node.repeated_tasks) == 2 + assert [repeat.comment for repeat in node.repeated_tasks] == [ + "First line\nSecond line", + None, + ] + + def test_parse_custom_todo_keys(): todo_keys = ['TODO', 'CUSTOM1', 'ANOTHER_KEYWORD'] done_keys = ['DONE', 'A']