From 303794081d3ec08b295f192fb045e7e3181fb81a Mon Sep 17 00:00:00 2001 From: Christian Stefanescu Date: Wed, 18 Feb 2026 20:01:20 +0100 Subject: [PATCH] feat: Support labels This adds support for printing existing labels as well as writing an .ics file per label. When writing to a single .ics file labels are stored as categories. --- tests/conftest.py | 61 +++++++++++ tests/test_event.py | 18 ++++ tests/test_formatter.py | 21 ++++ timetree_exporter/__main__.py | 161 ++++++++++++++++++++++++------ timetree_exporter/api/calendar.py | 82 +++++++++++++++ timetree_exporter/event.py | 22 ++++ timetree_exporter/formatter.py | 10 +- 7 files changed, 344 insertions(+), 31 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 0a25c78..f88adaa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -85,6 +85,67 @@ def memo_event_data(): } +@pytest.fixture +def labeled_event_data(): + """Fixture for event data with a label_id.""" + return { + "uuid": "test-uuid-labeled", + "title": "測試有標籤活動", + "created_at": 1713110000000, + "updated_at": 1713110100000, + "note": "", + "location": "", + "location_lat": None, + "location_lon": None, + "url": "", + "start_at": 1713120000000, + "start_timezone": "Asia/Taipei", + "end_at": 1713123600000, + "end_timezone": "Asia/Taipei", + "all_day": False, + "alerts": None, + "recurrences": None, + "parent_id": "", + "type": TimeTreeEventType.NORMAL, + "category": TimeTreeEventCategory.NORMAL, + "label_id": 3, + } + + +@pytest.fixture +def relationship_label_event_data(): + """Fixture for event data with label in relationships format.""" + return { + "uuid": "test-uuid-rel-label", + "title": "測試關係標籤活動", + "created_at": 1713110000000, + "updated_at": 1713110100000, + "note": "", + "location": "", + "location_lat": None, + "location_lon": None, + "url": "", + "start_at": 1713120000000, + "start_timezone": "Asia/Taipei", + "end_at": 1713123600000, + "end_timezone": "Asia/Taipei", + "all_day": False, + "alerts": None, + "recurrences": None, + "parent_id": "", + "type": TimeTreeEventType.NORMAL, + "category": TimeTreeEventCategory.NORMAL, + "relationships": { + "label": { + "data": { + "id": "12345,7", + "type": "label", + } + } + }, + } + + @pytest.fixture def temp_event_file(): """Create a temporary file with event data for testing.""" diff --git a/tests/test_event.py b/tests/test_event.py index 7a11505..e294030 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -93,3 +93,21 @@ def test_event_categories(): """Test the TimeTreeEventCategory enumeration.""" assert TimeTreeEventCategory.NORMAL == 1 assert TimeTreeEventCategory.MEMO == 2 + + +def test_from_dict_with_label_id(labeled_event_data): + """Test creating a TimeTreeEvent with a direct label_id.""" + event = TimeTreeEvent.from_dict(labeled_event_data) + assert event.label_id == 3 + + +def test_from_dict_with_relationship_label(relationship_label_event_data): + """Test creating a TimeTreeEvent with label in relationships format.""" + event = TimeTreeEvent.from_dict(relationship_label_event_data) + assert event.label_id == 7 + + +def test_from_dict_without_label(normal_event_data): + """Test that label_id is None when no label data is present.""" + event = TimeTreeEvent.from_dict(normal_event_data) + assert event.label_id is None diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 103cc2f..546f7aa 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -163,6 +163,27 @@ def test_no_alarms_location_url(normal_event_data): assert len(components) == 0 +def test_categories_with_label_name(normal_event_data): + """Test that CATEGORIES is set when label_name is provided.""" + event = TimeTreeEvent.from_dict(normal_event_data) + formatter = ICalEventFormatter(event, label_name="Work") + assert formatter.categories == "Work" + + ical_event = formatter.to_ical() + assert "CATEGORIES" in ical_event + assert ical_event["CATEGORIES"].cats == ["Work"] + + +def test_categories_without_label_name(normal_event_data): + """Test that CATEGORIES is not set when label_name is None.""" + event = TimeTreeEvent.from_dict(normal_event_data) + formatter = ICalEventFormatter(event) + assert formatter.categories is None + + ical_event = formatter.to_ical() + assert "CATEGORIES" not in ical_event + + def test_different_timezones(normal_event_data): """Test event with different start and end timezones.""" # Create an event with different start and end timezones diff --git a/timetree_exporter/__main__.py b/timetree_exporter/__main__.py index 19affe9..17b1e1f 100644 --- a/timetree_exporter/__main__.py +++ b/timetree_exporter/__main__.py @@ -5,6 +5,8 @@ import argparse import logging import os +import re +from collections import defaultdict from importlib.metadata import version from icalendar import Calendar from timetree_exporter import TimeTreeEvent, ICalEventFormatter, __version__ @@ -16,8 +18,8 @@ package_logger = logging.getLogger(__package__) -def get_events(email: str, password: str, calendar_code: str): - """Get events from the Timetree API.""" +def select_calendar(email: str, password: str, calendar_code: str): + """Authenticate and select a calendar. Returns (calendar_api, calendar_id, calendar_name).""" use_code = bool(calendar_code) session_id = login(email, password) calendar = TimeTreeCalendar(session_id) @@ -69,13 +71,41 @@ def get_events(email: str, password: str, calendar_code: str): idx = int(calendar_num) - 1 metadata = metadatas[idx] - # Get events from the selected calendar - calendar_id = metadata["id"] - calendar_name = metadata["name"] + return calendar, metadata["id"], metadata["name"] + +def get_events(email: str, password: str, calendar_code: str): + """Get events from the Timetree API.""" + calendar, calendar_id, calendar_name = select_calendar( + email, password, calendar_code + ) return calendar.get_events(calendar_id, calendar_name) +def create_calendar(): + """Create a new iCalendar object with standard properties.""" + cal = Calendar() + cal.add("prodid", f"-//TimeTree Exporter {version('timetree_exporter')}//EN") + cal.add("version", "2.0") + return cal + + +def write_calendar(cal, output_path): + """Write a calendar to an .ics file.""" + cal.add_missing_timezones() + with open(output_path, "wb") as f: + f.write(cal.to_ical()) + logger.info( + "The .ics calendar file is saved to %s", os.path.abspath(output_path) + ) + + +def sanitize_filename(name): + """Sanitize a string for use as a filename component.""" + # Replace any non-alphanumeric characters (except hyphen/underscore) with underscore + return re.sub(r"[^\w\-]", "_", name).strip("_") + + def main(): """Main function for the Timetree Exporter.""" # Parse arguments @@ -115,6 +145,16 @@ def main(): action="version", version=f"%(prog)s {__version__}", ) + parser.add_argument( + "--list-labels", + help="List labels for the selected calendar and exit", + action="store_true", + ) + parser.add_argument( + "--split-by-label", + help="Export events into separate .ics files grouped by label", + action="store_true", + ) args = parser.parse_args() if args.email: @@ -133,39 +173,100 @@ def main(): if args.verbose: package_logger.setLevel(logging.DEBUG) - # Set up calendar - cal = Calendar() - cal.add("prodid", f"-//TimeTree Exporter {version('timetree_exporter')}//EN") - cal.add("version", "2.0") + calendar_api, calendar_id, calendar_name = select_calendar( + email, password, args.calendar_code + ) - events = get_events(email, password, args.calendar_code) + # --list-labels: print labels and exit + if args.list_labels: + labels = calendar_api.get_labels(calendar_id) + if not labels: + print("No labels found (the API response format may differ — use -v to debug)") + else: + for i, (_label_id, label_info) in enumerate(labels.items(), 1): + print(f"{i}. {label_info['name']} ({label_info['color']})") + return + events = calendar_api.get_events(calendar_id, calendar_name) logger.info("Found %d events", len(events)) - # Add events to calendar - for event in events: - time_tree_event = TimeTreeEvent.from_dict(event) - formatter = ICalEventFormatter(time_tree_event) - ical_event = formatter.to_ical() - if ical_event is None: - continue - cal.add_component(ical_event) - - logger.info( - "A total of %d/%d events are added to the calendar", - len(cal.subcomponents), - len(events), - ) + # Fetch labels if splitting by label + labels = {} + if args.split_by_label: + labels = calendar_api.get_labels(calendar_id) + logger.info("Found %d labels", len(labels)) - # Add the required timezone information - cal.add_missing_timezones() + if args.split_by_label: + # Group events by label_id + grouped = defaultdict(list) + for event in events: + time_tree_event = TimeTreeEvent.from_dict(event) + formatter_label_name = None + group_key = None - # Write calendar to file - with open(args.output, "wb") as f: # Path Traversal Vulnerability if on a server - f.write(cal.to_ical()) + if time_tree_event.label_id is not None and time_tree_event.label_id in labels: + label_info = labels[time_tree_event.label_id] + formatter_label_name = label_info["name"] + group_key = time_tree_event.label_id + else: + group_key = None # unlabeled + + formatter = ICalEventFormatter(time_tree_event, label_name=formatter_label_name) + ical_event = formatter.to_ical() + if ical_event is not None: + grouped[group_key].append(ical_event) + + # Write each group to a separate file + output_stem, output_ext = os.path.splitext(args.output) + if not output_ext: + output_ext = ".ics" + + for group_key, ical_events in grouped.items(): + if group_key is None: + label_suffix = "unlabeled" + else: + label_suffix = sanitize_filename(labels[group_key]["name"]) + + cal = create_calendar() + for ical_event in ical_events: + cal.add_component(ical_event) + + output_path = f"{output_stem}_{label_suffix}{output_ext}" + logger.info( + "%d events for label '%s'", len(ical_events), label_suffix + ) + write_calendar(cal, output_path) + + total = sum(len(evts) for evts in grouped.values()) logger.info( - "The .ics calendar file is saved to %s", os.path.abspath(args.output) + "A total of %d/%d events split into %d files", + total, len(events), len(grouped), ) + else: + # Standard single-file export + cal = create_calendar() + + # Build label lookup for CATEGORIES + label_lookup = {} + if labels: + label_lookup = {lid: info["name"] for lid, info in labels.items()} + + for event in events: + time_tree_event = TimeTreeEvent.from_dict(event) + label_name = label_lookup.get(time_tree_event.label_id) + formatter = ICalEventFormatter(time_tree_event, label_name=label_name) + ical_event = formatter.to_ical() + if ical_event is None: + continue + cal.add_component(ical_event) + + logger.info( + "A total of %d/%d events are added to the calendar", + len(cal.subcomponents), + len(events), + ) + + write_calendar(cal, args.output) if __name__ == "__main__": diff --git a/timetree_exporter/api/calendar.py b/timetree_exporter/api/calendar.py index 5b68b48..7d34d00 100644 --- a/timetree_exporter/api/calendar.py +++ b/timetree_exporter/api/calendar.py @@ -39,6 +39,88 @@ def get_metadata(self): raise HTTPError("Failed to get calendar metadata") return response.json()["calendars"] + def get_labels(self, calendar_id: int): + """ + Get labels for a calendar. + + Tries multiple endpoint patterns since the internal API format + may differ from the official JSON:API. + """ + headers = { + "Content-Type": "application/json", + "X-Timetreea": API_USER_AGENT, + } + + # Call dedicated labels endpoint (internal API pattern) + url = f"{API_BASEURI}/calendar/{calendar_id}/labels" + response = self.session.get(url, headers=headers) + logger.debug( + "GET %s -> %d\n%s", + url, response.status_code, + json.dumps(response.json(), indent=2, ensure_ascii=False) + if response.status_code == 200 else response.text, + ) + + labels = {} + + if response.status_code == 200: + r_json = response.json() + labels = self._parse_labels(r_json) + + logger.debug("Parsed %d labels: %s", len(labels), labels) + return labels + + @staticmethod + def _format_color(color): + """Convert a color value to hex string if it's an integer.""" + if isinstance(color, int): + return f"#{color:06x}" + return color + + @staticmethod + def _parse_labels(r_json): + """Parse labels from various API response formats.""" + labels = {} + + # Internal API format: `calendar_labels` array + if "calendar_labels" in r_json: + for label in r_json["calendar_labels"]: + label_id = label.get("id") + labels[label_id] = { + "name": label.get("name", ""), + "color": TimeTreeCalendar._format_color(label.get("color", "")), + } + if labels: + return labels + + # JSON:API `included` array format (official API) + if "included" in r_json: + for item in r_json["included"]: + if item.get("type") == "label": + label_id = item.get("id") + attrs = item.get("attributes", {}) + labels[label_id] = { + "name": attrs.get("name", ""), + "color": TimeTreeCalendar._format_color( + attrs.get("color", "") + ), + } + if labels: + return labels + + # Direct `labels` array at top level + if "labels" in r_json: + for label in r_json["labels"]: + label_id = label.get("id") + labels[label_id] = { + "name": label.get("name", ""), + "color": TimeTreeCalendar._format_color(label.get("color", "")), + } + if labels: + return labels + + return labels + def get_events_recur(self, calendar_id: int, since: int): """ Get events from the calendar.(Recursively) diff --git a/timetree_exporter/event.py b/timetree_exporter/event.py index 5070615..a88be49 100644 --- a/timetree_exporter/event.py +++ b/timetree_exporter/event.py @@ -28,6 +28,27 @@ class TimeTreeEvent: parent_id: str event_type: int category: int + label_id: int = None + + @staticmethod + def _extract_label_id(event_data: dict): + """Extract label_id from event data, trying multiple formats.""" + # Try direct `label_id` key + label_id = event_data.get("label_id") + if label_id is not None: + return label_id + + # Try JSON:API relationships format + try: + rel_id = event_data["relationships"]["label"]["data"]["id"] + # Format may be "calendar_id,label_number" — extract the label number + if isinstance(rel_id, str) and "," in rel_id: + return int(rel_id.split(",")[-1]) + return int(rel_id) + except (KeyError, TypeError, ValueError): + pass + + return None @classmethod def from_dict(cls, event_data: dict): @@ -52,6 +73,7 @@ def from_dict(cls, event_data: dict): parent_id=event_data.get("parent_id"), event_type=event_data.get("type"), category=event_data.get("category"), + label_id=cls._extract_label_id(event_data), ) def __str__(self): diff --git a/timetree_exporter/formatter.py b/timetree_exporter/formatter.py index 1023d9b..24db59c 100644 --- a/timetree_exporter/formatter.py +++ b/timetree_exporter/formatter.py @@ -25,8 +25,14 @@ class ICalEventFormatter: Class for formatting TimeTree events into iCalendar format. """ - def __init__(self, time_tree_event: TimeTreeEvent): + def __init__(self, time_tree_event: TimeTreeEvent, label_name: str = None): self.time_tree_event = time_tree_event + self.label_name = label_name + + @property + def categories(self): + """Return the label name as CATEGORIES.""" + return self.label_name @property def uid(self): @@ -209,6 +215,8 @@ def to_ical(self) -> Event: event.add("description", self.description) if self.related_to: event.add("related-to", self.related_to) + if self.categories: + event.add("categories", [self.categories]) for alarm in self.alarms: event.add_component(alarm)