diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e5d5825..4d0dfac 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -86,6 +86,17 @@ uv run pytest tests/test_calendar.py tests/test_event.py tests/test_main.py uv run ruff check ``` +### Activity Endpoint (Comments) + +When `--include-comments` is used, the exporter fetches event comments from a separate endpoint: + +- Endpoint: `/api/v1/calendar/{calendar_id}/event/{event_id}/activities?since=0` +- This is not part of the event payload; it's a per-event API call +- Response includes `event_activities` array with comment text in `comment.body`, `attachment.content`, or other message fields +- Pagination is supported via `chunk: true` and `since: ` +- Comment author names are resolved from `calendar_users` metadata and prefixed when available +- Default behavior (`--include-comments` off) skips these calls to avoid performance impact and rate-limit risk + ## Before Opening A PR Run these checks: diff --git a/README.md b/README.md index d451607..36572fe 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,30 @@ Then, you can import the ics file to your calendar app. This creates individual ICS files for each label (e.g., `timetree_work.ics`, `timetree_personal.ics`). +- Include private event comments. + + ```bash + timetree-exporter --include-comments + ``` + + Comments are fetched from TimeTree's event activities endpoint and exported as iCalendar `COMMENT` properties, prefixed with the author name when available. + + To change the number of concurrent requests used while fetching comments, pass `--num-workers`. + + ```bash + timetree-exporter --include-comments --num-workers 5 + ``` + + The default is `10`. + + > [!Caution] + > This option is disabled by default because it makes one or more extra TimeTree requests per event. It can be slow for large calendars and may trigger TimeTree rate limits. + ## Limitations - TimeTree labels include both a category name and a color. When using `--split-by-label`, each category is saved as a separate ICS file. - Label color information is preserved in the ICS output, but Google Calendar does not apply those event colors when importing ICS files. If you rely on colors to organize events, you may need to check historical color information in TimeTree. +- TimeTree event notes are exported as the iCalendar `DESCRIPTION`. Private event comments are only exported when `--include-comments` is used. ## Support diff --git a/tests/test_calendar.py b/tests/test_calendar.py index d8f3c9f..0631d74 100644 --- a/tests/test_calendar.py +++ b/tests/test_calendar.py @@ -45,6 +45,16 @@ def get(self, _url, **kwargs): return _FakeResponse(self._payloads.pop(0)) +class _UrlPayloadSession: + def __init__(self, payloads): + self._payloads = payloads + self.requested_urls = [] + + def get(self, url, **_kwargs): + self.requested_urls.append(url) + return _FakeResponse(self._payloads[url]) + + def _calendar_with_metadata_response(payload, capture_raw_responses=True): calendar = TimeTreeCalendar("dummy-session-id", capture_raw_responses=capture_raw_responses) calendar.session = _FakeSession(payload) @@ -168,6 +178,81 @@ def test_get_public_events_uses_public_calendar_endpoint(): assert events == [{"id": "public-event-id"}] +def test_get_events_adds_comments_from_activity_endpoint(): + """Private event exports should include event comments from activities.""" + calendar = TimeTreeCalendar("dummy-session-id") + session = _UrlPayloadSession( + { + "https://timetreeapp.com/api/v1/calendar/1/events/sync": { + "events": [{"id": 10, "uuid": "event-uuid"}], + "chunk": False, + }, + "https://timetreeapp.com/api/v1/calendar/1/event/10/activities?since=0": { + "event_activities": [ + {"author_id": 10, "comment": {"body": "First comment"}}, + {"author_id": 11, "comment": "Second comment"}, + {"author_id": 12, "attachment": {"content": "Third comment"}}, + {"action": "updated"}, + ], + "chunk": True, + "since": 123, + }, + "https://timetreeapp.com/api/v1/calendar/1/event/10/activities?since=123": { + "activities": [{"author_id": 10, "message": "Fourth comment"}], + "chunk": False, + }, + } + ) + calendar.session = session + + events = calendar.get_events( + 1, + "Family", + [ + {"user_id": 10, "name": "Alice"}, + {"id": 11, "name": "Bob"}, + ], + include_comments=True, + ) + + assert session.requested_urls == [ + "https://timetreeapp.com/api/v1/calendar/1/events/sync", + "https://timetreeapp.com/api/v1/calendar/1/event/10/activities?since=0", + "https://timetreeapp.com/api/v1/calendar/1/event/10/activities?since=123", + ] + assert events == [ + { + "id": 10, + "uuid": "event-uuid", + "comments": [ + "Alice: First comment", + "Bob: Second comment", + "Third comment", + "Alice: Fourth comment", + ], + } + ] + + +def test_get_events_does_not_fetch_comments_by_default(): + """Private event exports should avoid per-event activity calls by default.""" + calendar = TimeTreeCalendar("dummy-session-id") + session = _UrlPayloadSession( + { + "https://timetreeapp.com/api/v1/calendar/1/events/sync": { + "events": [{"id": 10, "uuid": "event-uuid"}], + "chunk": False, + } + } + ) + calendar.session = session + + events = calendar.get_events(1, "Family") + + assert session.requested_urls == ["https://timetreeapp.com/api/v1/calendar/1/events/sync"] + assert events == [{"id": 10, "uuid": "event-uuid"}] + + def test_get_public_events_follows_pagination_cursor(): """Public calendar exports should follow the public_events paging envelope.""" calendar = TimeTreeCalendar("dummy-session-id") diff --git a/tests/test_event.py b/tests/test_event.py index db4fa93..fb2f853 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -81,6 +81,16 @@ def test_from_dict(normal_event_data): assert event.category == normal_event_data["category"] +def test_from_dict_with_comments(normal_event_data): + """Test creating a TimeTreeEvent with activity comments.""" + data = normal_event_data.copy() + data["comments"] = ["First comment", "Second comment"] + + event = TimeTreeEvent.from_dict(data) + + assert event.comments == ["First comment", "Second comment"] + + def test_str_representation(normal_event_data): """Test the string representation of a TimeTreeEvent.""" event = TimeTreeEvent.from_dict(normal_event_data) diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 10e3f41..a0991cc 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -90,6 +90,22 @@ def test_to_ical_normal_event(normal_event_data): assert ical_event["RRULE"]["COUNT"] == [5] +def test_to_ical_includes_comments(normal_event_data): + """Event comments should be exported as COMMENT properties.""" + data = normal_event_data.copy() + data["comments"] = ["First comment", "Second comment"] + event = TimeTreeEvent.from_dict(data) + formatter = ICalEventFormatter(event) + + assert formatter.description == "測試備註" + assert formatter.comments == ["First comment", "Second comment"] + + ical_event = formatter.to_ical() + + assert ical_event["description"] == "測試備註" + assert ical_event["comment"] == ["First comment", "Second comment"] + + def test_related_to_prefers_recurring_uuid(normal_event_data): """RELATED-TO should reference the parent event UID, not internal parent id.""" data = normal_event_data.copy() diff --git a/tests/test_main.py b/tests/test_main.py index 55484f9..52e17fb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -36,10 +36,21 @@ def __init__(self, events, labels): self.events = events self.labels = labels self.fetched_events_for = None + self.fetched_calendar_users = None self.fetched_labels_for = None - def get_events(self, calendar_id, calendar_name): + def get_events( + self, + calendar_id, + calendar_name, + calendar_users=None, + include_comments=False, + num_workers=10, + ): self.fetched_events_for = (calendar_id, calendar_name) + self.fetched_calendar_users = calendar_users + self.include_comments = include_comments + self.num_workers = num_workers return self.events def get_labels(self, calendar_id): @@ -174,16 +185,37 @@ def test_exporter_fetches_labels_and_writes_single_calendar(tmp_path, normal_eve """Exporter should own fetching labels, fetching events, and writing output.""" api = _FakeExportCalendarApi([normal_event_data], {}) output_path = tmp_path / "calendar.ics" - calendar = Calendar(api, {"id": "calendar-id", "name": "Calendar Name", "alias_code": "code"}) + calendar = Calendar( + api, + { + "id": "calendar-id", + "name": "Calendar Name", + "alias_code": "code", + "calendar_users": [{"user_id": 10, "name": "Alice"}], + }, + ) Exporter(calendar, output_path).export() serialized = output_path.read_text(encoding="utf-8") assert api.fetched_events_for == ("calendar-id", "Calendar Name") + assert api.fetched_calendar_users == [{"user_id": 10, "name": "Alice"}] + assert api.include_comments is False assert api.fetched_labels_for == "calendar-id" assert "SUMMARY:測試一般活動" in serialized +def test_exporter_can_include_comments(tmp_path, normal_event_data): + """Exporter should pass through opt-in comment export.""" + api = _FakeExportCalendarApi([normal_event_data], {}) + output_path = tmp_path / "calendar.ics" + calendar = Calendar(api, {"id": "calendar-id", "name": "Calendar Name"}) + + Exporter(calendar, output_path, include_comments=True).export() + + assert api.include_comments is True + + def test_exporter_writes_split_calendars_by_label(tmp_path, labeled_event_data): """Exporter should write split files when configured to split by label.""" api = _FakeExportCalendarApi( diff --git a/timetree_exporter/__main__.py b/timetree_exporter/__main__.py index 2a32f3e..a1881c7 100644 --- a/timetree_exporter/__main__.py +++ b/timetree_exporter/__main__.py @@ -98,7 +98,13 @@ def main(): email = resolve_email(args.email) password = resolve_password() calendar = select_calendar(email, password, args.calendar_code) - exporter = Exporter(calendar, args.output, split_by_label=args.split_by_label) + exporter = Exporter( + calendar, + args.output, + split_by_label=args.split_by_label, + include_comments=args.include_comments, + num_workers=args.num_workers, + ) if args.list_labels: list_labels_and_exit(calendar) diff --git a/timetree_exporter/api/calendar.py b/timetree_exporter/api/calendar.py index 56833c5..ee01eca 100644 --- a/timetree_exporter/api/calendar.py +++ b/timetree_exporter/api/calendar.py @@ -5,6 +5,7 @@ import json import logging import re +from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path import requests @@ -190,7 +191,129 @@ def get_events_recur(self, calendar_id: int, since: int): events.extend(self.get_events_recur(calendar_id, r_json["since"])) return events - def get_events(self, calendar_id: int, calendar_name: str = None): + @staticmethod + def _extract_activity_comment(activity): + """Return comment text from a TimeTree activity payload when present.""" + comment = activity.get("comment") + if isinstance(comment, str): + return comment + if isinstance(comment, dict): + for key in ("body", "text", "content", "message"): + if comment.get(key): + return comment[key] + + attachment = activity.get("attachment") + if isinstance(attachment, dict) and attachment.get("content"): + return attachment["content"] + + for key in ("body", "text", "content", "message"): + if activity.get(key): + return activity[key] + + return None + + @staticmethod + def _calendar_user_names(calendar_users): + """Return calendar user names keyed by user id.""" + names = {} + for user in calendar_users or []: + user_id = user.get("user_id") or user.get("id") + name = user.get("name") + if user_id is not None and name: + names[user_id] = name + return names + + @staticmethod + def _format_activity_comment(activity, comment, user_names): + """Add the activity author name to a comment when known.""" + author_name = user_names.get(activity.get("author_id")) + if author_name: + return f"{author_name}: {comment}" + return comment + + def get_event_activities( + self, + calendar_id: int, + event_id: int, + since: int = 0, + user_names=None, + ): + """Get activities for an event.""" + user_names = user_names or {} + url = f"{API_BASEURI}/calendar/{calendar_id}/event/{event_id}/activities?since={since}" + response = self.session.get( + url, + headers={ + "Content-Type": "application/json", + "X-Timetreea": API_USER_AGENT, + }, + ) + if response.status_code != 200: + logger.warning("Failed to get activities for event %s", event_id) + logger.debug(response.text) + return [] + + r_json = response.json() + self._record_raw_response( + f"calendar_{calendar_id}/event_{event_id}/activities_since_{since}", r_json + ) + activities = r_json.get("activities") or r_json.get("event_activities", []) + comments = [] + for activity in activities: + comment = self._extract_activity_comment(activity) + if comment: + comments.append(self._format_activity_comment(activity, comment, user_names)) + if r_json.get("chunk") is True: + comments.extend( + self.get_event_activities(calendar_id, event_id, r_json["since"], user_names) + ) + return comments + + def add_event_comments(self, calendar_id: int, events, calendar_users=None, num_workers=10): + """Attach comments from event activities to event payloads using thread pool.""" + user_names = self._calendar_user_names(calendar_users) + + # Create a mapping of event_id to event for result collection + events_by_id = {event.get("id"): event for event in events if event.get("id")} + + if not events_by_id: + return events + + # Use ThreadPoolExecutor to fetch activities concurrently. + max_workers = max(1, num_workers) + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + # Submit all activity fetch tasks + future_to_event_id = { + executor.submit( + self.get_event_activities, + calendar_id, + event_id, + user_names=user_names, + ): event_id + for event_id in events_by_id + } + + # Collect results as they complete + for future in as_completed(future_to_event_id): + event_id = future_to_event_id[future] + try: + comments = future.result() + if comments: + events_by_id[event_id]["comments"] = comments + except Exception as e: + logger.warning("Failed to fetch activities for event %s: %s", event_id, e) + + return events + + def get_events( + self, + calendar_id: int, + calendar_name: str = None, + calendar_users=None, + include_comments: bool = False, + num_workers: int = 10, + ): """ Get events from the calendar. """ @@ -218,6 +341,12 @@ def get_events(self, calendar_id: int, calendar_name: str = None): json.dumps(events[:5], indent=2, ensure_ascii=False), ) + if include_comments: + logger.warning( + "Exporting comments requires extra TimeTree requests per event and may take " + "much longer or trigger rate limits." + ) + return self.add_event_comments(calendar_id, events, calendar_users, num_workers) return events def get_public_events(self, calendar_id: int, calendar_name: str = None): diff --git a/timetree_exporter/calendar.py b/timetree_exporter/calendar.py index 501e4a7..ecc7a3e 100644 --- a/timetree_exporter/calendar.py +++ b/timetree_exporter/calendar.py @@ -7,7 +7,14 @@ class CalendarApi(Protocol): """API methods needed by a selected calendar.""" - def get_events(self, calendar_id, calendar_name): + def get_events( + self, + calendar_id, + calendar_name, + calendar_users=None, + include_comments=False, + num_workers=10, + ): """Return events for a calendar.""" def get_public_events(self, calendar_id, calendar_name): @@ -47,11 +54,17 @@ def is_public(self): """Return whether this calendar should use the public calendar API.""" return self.metadata.get("public", False) - def get_events(self): + def get_events(self, include_comments=False, num_workers=10): """Return events for this calendar.""" if self.is_public: return self.api.get_public_events(self.id, self.name) - return self.api.get_events(self.id, self.name) + return self.api.get_events( + self.id, + self.name, + self.metadata.get("calendar_users"), + include_comments=include_comments, + num_workers=num_workers, + ) def get_labels(self): """Return labels for this calendar.""" diff --git a/timetree_exporter/cli.py b/timetree_exporter/cli.py index 84540a0..90c12a4 100644 --- a/timetree_exporter/cli.py +++ b/timetree_exporter/cli.py @@ -81,6 +81,20 @@ def parse_args(): help="Export events into separate .ics files grouped by label", action="store_true", ) + parser.add_argument( + "--include-comments", + help=( + "Export private event comments. This makes one or more extra TimeTree requests " + "per event and may take much longer or trigger rate limits." + ), + action="store_true", + ) + parser.add_argument( + "--num-workers", + type=int, + help="Number of concurrent threads for fetching comments (default: 10)", + default=10, + ) parser.add_argument( "--developer-mode", help=( diff --git a/timetree_exporter/event.py b/timetree_exporter/event.py index 958d807..61d2120 100644 --- a/timetree_exporter/event.py +++ b/timetree_exporter/event.py @@ -30,6 +30,7 @@ class TimeTreeEvent: event_type: int category: int label_id: int = None + comments: list = None @staticmethod def _extract_label_id(event_data: dict): @@ -76,6 +77,7 @@ def from_dict(cls, event_data: dict): event_type=event_data.get("type"), category=event_data.get("category"), label_id=cls._extract_label_id(event_data), + comments=event_data.get("comments"), ) def __str__(self): diff --git a/timetree_exporter/exporter.py b/timetree_exporter/exporter.py index 96c1929..a0fe343 100644 --- a/timetree_exporter/exporter.py +++ b/timetree_exporter/exporter.py @@ -17,14 +17,20 @@ class Exporter: """Export a selected TimeTree calendar to one or more iCalendar files.""" - def __init__(self, calendar, output, split_by_label=False): + def __init__( + self, calendar, output, split_by_label=False, include_comments=False, num_workers=10 + ): self.calendar = calendar self.output = output self.split_by_label = split_by_label + self.include_comments = include_comments + self.num_workers = num_workers def export(self): """Fetch labels and events, then write the configured iCalendar output.""" - events = self.calendar.get_events() + events = self.calendar.get_events( + include_comments=self.include_comments, num_workers=self.num_workers + ) logger.info("Found %d events", len(events)) labels = self.calendar.get_labels() diff --git a/timetree_exporter/formatter.py b/timetree_exporter/formatter.py index 190a5f5..b28e894 100644 --- a/timetree_exporter/formatter.py +++ b/timetree_exporter/formatter.py @@ -101,6 +101,11 @@ def description(self): """Return the note of the event.""" return self.time_tree_event.note if self.time_tree_event.note != "" else None + @property + def comments(self): + """Return event comments.""" + return self.time_tree_event.comments or [] + @property def location(self): """Return the location of the event.""" @@ -284,6 +289,8 @@ def to_ical(self) -> Event: event.add("image", image_url, parameters={"VALUE": "URI", "DISPLAY": "THUMBNAIL"}) if self.description: event.add("description", self.description) + for comment in self.comments: + event.add("comment", comment) if self.related_to: event.add("related-to", self.related_to) if self.categories: