1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
|
"""Library for handling rfc5545 components.
An iCalendar object consists of one or more components, that may have
properties or sub-components. An example of a component might be the
calendar itself, an event, a to-do, a journal entry, timezone info, etc.
Components created here have no semantic meaning, but hold all the
data needed to interpret based on the type (e.g. by a pydantic model)
"""
# mypy: allow-any-generics
from __future__ import annotations
import re
import textwrap
from dataclasses import dataclass, field
from collections.abc import Generator
from .const import (
ATTR_BEGIN,
ATTR_END,
FOLD,
FOLD_INDENT,
FOLD_LEN,
ATTR_BEGIN_LOWER,
ATTR_END_LOWER,
)
from .property import ParsedProperty, parse_contentlines
FOLD_RE = re.compile(FOLD, flags=re.MULTILINE)
LINES_RE = re.compile(r"\r?\n")
@dataclass
class ParsedComponent:
"""An rfc5545 component."""
name: str
properties: list[ParsedProperty] = field(default_factory=list)
components: list[ParsedComponent] = field(default_factory=list)
def as_dict(self) -> dict[str, str | list[ParsedProperty | dict]]:
"""Convert the component into a pydantic parseable dictionary."""
result: dict[str, list[ParsedProperty | dict]] = {}
for prop in self.properties:
result.setdefault(prop.name, [])
result[prop.name].append(prop)
for component in self.components:
result.setdefault(component.name, [])
result[component.name].append(component.as_dict())
return {
"name": self.name,
**result,
}
def ics(self) -> str:
"""Encode a component as rfc5545 text."""
contentlines = []
name = self.name.upper()
contentlines.append(f"{ATTR_BEGIN}:{name}")
for prop in self.properties:
contentlines.extend(_fold(prop.ics()))
contentlines.extend([component.ics() for component in self.components])
contentlines.append(f"{ATTR_END}:{name}")
return "\n".join(contentlines)
def _fold(contentline: str) -> list[str]:
return textwrap.wrap(
contentline,
width=FOLD_LEN,
subsequent_indent=FOLD_INDENT,
drop_whitespace=False,
replace_whitespace=False,
expand_tabs=False,
break_on_hyphens=False,
)
def parse_content(content: str) -> list[ParsedComponent]:
"""Parse content into raw properties.
This includes all necessary unfolding of long lines into full properties.
This is fairly straight forward in that it walks through each line and uses
a stack to associate properties with the current object. This does the absolute
minimum possible parsing into a dictionary of objects to get the right structure.
All the more detailed parsing of the objects is handled by pydantic, elsewhere.
"""
lines = unfolded_lines(content)
properties = parse_contentlines(lines)
stack: list[ParsedComponent] = [ParsedComponent(name="stream")]
for prop in properties:
if prop.name == ATTR_BEGIN_LOWER:
stack.append(ParsedComponent(name=prop.value.lower()))
elif prop.name == ATTR_END_LOWER:
component = stack.pop()
if prop.value.lower() != component.name:
raise ValueError(
f"Unexpected '{prop}', expected {ATTR_END}:{component.name}"
)
stack[-1].components.append(component)
else:
stack[-1].properties.append(prop)
return stack[0].components
def encode_content(components: list[ParsedComponent]) -> str:
"""Encode a set of parsed properties into content."""
return "\n".join([component.ics() for component in components])
def unfolded_lines(content: str) -> Generator[str, None, None]:
"""Read content and unfold lines."""
content = FOLD_RE.sub("", content)
yield from LINES_RE.split(content)
|