Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <timestamp>`
- 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:
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
85 changes: 85 additions & 0 deletions tests/test_calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
10 changes: 10 additions & 0 deletions tests/test_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions tests/test_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
36 changes: 34 additions & 2 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion timetree_exporter/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading