Skip to content
Open
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ Then, you can import the ics file to your calendar app.
> [!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.

- Include images attached to private events.

```bash
timetree-exporter --include-images --num-workers 2
```

Images are saved beside the ICS output in `timetree_images/`, grouped by event UUID. The
`timetree_images.json` file maps each image to its event UUID, title, start date, object key,
and relative path. Existing image files are skipped, so the export can be run again safely.
This option applies only to private calendars and makes additional activity and image requests.

## 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.
Expand Down
68 changes: 68 additions & 0 deletions tests/test_calendar.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
"""Tests for TimeTree calendar API helpers."""

from pathlib import Path

import pytest
from requests.exceptions import Timeout

from timetree_exporter.api.calendar import TimeTreeCalendar
from timetree_exporter.config import configure_developer_mode

Expand Down Expand Up @@ -55,6 +60,15 @@ def get(self, url, **_kwargs):
return _FakeResponse(self._payloads[url])


class _TimeoutSession:
def __init__(self):
self.requested_kwargs = None

def get(self, _url, **kwargs):
self.requested_kwargs = kwargs
raise Timeout("attachment download timed out")


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 @@ -234,6 +248,60 @@ def test_get_events_adds_comments_from_activity_endpoint():
]


def test_get_events_fetches_comments_and_images_from_one_activity_request():
"""Comments and image attachments should share one activity fetch per event."""
calendar = TimeTreeCalendar("dummy-session-id")
session = _UrlPayloadSession(
{
"https://timetreeapp.com/api/v1/calendar/1/events/sync": {
"events": [{"uuid": "event-uuid", "title": "Trip"}],
"chunk": False,
},
"https://timetreeapp.com/api/v1/calendar/1/event/event-uuid/activities?since=0": {
"activities": [
{
"author_id": 10,
"comment": {"body": "See attached"},
"attachment": {"images": [{"object_key": "calendar/18d9/photo.jpg"}]},
}
],
"chunk": False,
},
}
)
calendar.session = session

events = calendar.get_events(
1,
"Family",
[{"user_id": 10, "name": "Alice"}],
include_comments=True,
include_images=True,
)

assert session.requested_urls == [
"https://timetreeapp.com/api/v1/calendar/1/events/sync",
"https://timetreeapp.com/api/v1/calendar/1/event/event-uuid/activities?since=0",
]
assert events[0]["comments"] == ["Alice: See attached"]
assert events[0]["_image_attachments"] == [{"object_key": "calendar/18d9/photo.jpg"}]


def test_download_image_uses_timeout_and_warns_on_timeout(tmp_path, caplog):
"""Attachment timeouts should be bounded and reported without creating a file."""
calendar = TimeTreeCalendar("dummy-session-id")
session = _TimeoutSession()
calendar.session = session
output_path = Path(tmp_path) / "image.jpg"

with pytest.raises(Timeout):
calendar.download_image("calendar/18d9/image.jpg", output_path)

assert session.requested_kwargs["timeout"] == (10, 60)
assert not output_path.exists()
assert "Timed out downloading image calendar/18d9/image.jpg" in caplog.text


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")
Expand Down
46 changes: 46 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,29 @@ def __init__(self, events, labels):
self.fetched_events_for = None
self.fetched_calendar_users = None
self.fetched_labels_for = None
self.downloaded_images = []

def get_events(
self,
calendar_id,
calendar_name,
calendar_users=None,
include_comments=False,
include_images=False,
num_workers=10,
):
self.fetched_events_for = (calendar_id, calendar_name)
self.fetched_calendar_users = calendar_users
self.include_comments = include_comments
self.include_images = include_images
self.num_workers = num_workers
return self.events

def download_image(self, object_key, output_path):
self.downloaded_images.append((object_key, output_path))
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(b"image data")

def get_labels(self, calendar_id):
self.fetched_labels_for = calendar_id
return self.labels
Expand Down Expand Up @@ -216,6 +224,44 @@ def test_exporter_can_include_comments(tmp_path, normal_event_data):
assert api.include_comments is True


def test_exporter_writes_event_images_and_manifest(tmp_path, normal_event_data):
"""Private event images should be saved beside the ICS with a manifest."""
event = normal_event_data.copy()
event["_image_attachments"] = [{"object_key": "calendar/18d9/photo.jpg"}]
api = _FakeExportCalendarApi([event], {})
output_path = tmp_path / "calendar.ics"
calendar = Calendar(api, {"id": "calendar-id", "name": "Calendar Name"})

Exporter(calendar, output_path, include_images=True).export()

image_path = tmp_path / "timetree_images/test-uuid-normal/calendar/18d9/photo.jpg"
assert image_path.read_bytes() == b"image data"
assert api.include_images is True
assert api.downloaded_images == [("calendar/18d9/photo.jpg", image_path)]
assert (tmp_path / "timetree_images.json").read_text(encoding="utf-8") == (
'[\n {\n "event_uuid": "test-uuid-normal",\n "title": "測試一般活動",\n'
' "start_date": "2024-04-15",\n "image_path": '
'"timetree_images/test-uuid-normal/calendar/18d9/photo.jpg",\n'
' "object_key": "calendar/18d9/photo.jpg"\n }\n]\n'
)


def test_exporter_skips_existing_event_images(tmp_path, normal_event_data):
"""Existing image files should make image export safely repeatable."""
event = normal_event_data.copy()
event["_image_attachments"] = [{"object_key": "calendar/18d9/photo.jpg"}]
image_path = tmp_path / "timetree_images/test-uuid-normal/calendar/18d9/photo.jpg"
image_path.parent.mkdir(parents=True)
image_path.write_bytes(b"existing image")
api = _FakeExportCalendarApi([event], {})
calendar = Calendar(api, {"id": "calendar-id", "name": "Calendar Name"})

Exporter(calendar, tmp_path / "calendar.ics", include_images=True).export()

assert image_path.read_bytes() == b"existing image"
assert api.downloaded_images == []


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
1 change: 1 addition & 0 deletions timetree_exporter/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ def main():
args.output,
split_by_label=args.split_by_label,
include_comments=args.include_comments,
include_images=args.include_images,
num_workers=args.num_workers,
)

Expand Down
125 changes: 103 additions & 22 deletions timetree_exporter/api/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@
from pathlib import Path

import requests
from requests.exceptions import HTTPError

from timetree_exporter.api.const import API_BASEURI, API_USER_AGENT, API_V2_BASEURI
from requests.exceptions import HTTPError, Timeout

from timetree_exporter.api.const import (
API_BASEURI,
API_USER_AGENT,
API_V2_BASEURI,
ATTACHMENT_TIMEOUT,
)
from timetree_exporter.config import get_raw_output_dir

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -240,6 +245,27 @@ def get_event_activities(
):
"""Get activities for an event."""
user_names = user_names or {}
comments = []
for activity in self._fetch_event_activities(calendar_id, event_uuid, since):
comment = self._extract_activity_comment(activity)
if comment:
comments.append(self._format_activity_comment(activity, comment, user_names))
return comments

@staticmethod
def _extract_activity_images(activities):
"""Return image attachment metadata from event activities."""
images = []
for activity in activities:
attachment = activity.get("attachment") or {}
for image in attachment.get("images") or []:
object_key = image.get("object_key")
if object_key:
images.append({"object_key": object_key})
return images

def _fetch_event_activities(self, calendar_id, event_uuid, since=0):
"""Fetch raw activities, including all pages, for one event."""
url = f"{API_BASEURI}/calendar/{calendar_id}/event/{event_uuid}/activities?since={since}"
response = self.session.get(
url,
Expand All @@ -258,19 +284,22 @@ def get_event_activities(
f"calendar_{calendar_id}/event_{event_uuid}/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_uuid, r_json["since"], user_names)
activities.extend(
self._fetch_event_activities(calendar_id, event_uuid, r_json["since"])
)
return comments
return activities

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."""
def add_event_activities(
self,
calendar_id: int,
events,
calendar_users=None,
include_comments=False,
include_images=False,
num_workers=10,
):
"""Attach requested activity data to event payloads using one fetch per event."""
user_names = self._calendar_user_names(calendar_users)

events_by_uuid = {event.get("uuid"): event for event in events if event.get("uuid")}
Expand All @@ -285,10 +314,9 @@ def add_event_comments(self, calendar_id: int, events, calendar_users=None, num_
# Submit all activity fetch tasks
future_to_event_uuid = {
executor.submit(
self.get_event_activities,
self._fetch_event_activities,
calendar_id,
event_uuid,
user_names=user_names,
): event_uuid
for event_uuid in events_by_uuid
}
Expand All @@ -297,21 +325,44 @@ def add_event_comments(self, calendar_id: int, events, calendar_users=None, num_
for future in as_completed(future_to_event_uuid):
event_uuid = future_to_event_uuid[future]
try:
comments = future.result()
if comments:
events_by_uuid[event_uuid]["comments"] = comments
activities = future.result()
if include_comments:
comments = []
for activity in activities:
comment = self._extract_activity_comment(activity)
if comment:
comments.append(
self._format_activity_comment(activity, comment, user_names)
)
if comments:
events_by_uuid[event_uuid]["comments"] = comments
if include_images:
images = self._extract_activity_images(activities)
if images:
events_by_uuid[event_uuid]["_image_attachments"] = images
except Exception as e:
logger.warning("Failed to fetch activities for event %s: %s", event_uuid, e)

return events

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."""
return self.add_event_activities(
calendar_id,
events,
calendar_users=calendar_users,
include_comments=True,
num_workers=num_workers,
)

def get_events(
self,
calendar_id: int,
calendar_name: str = None,
calendar_users=None,
include_comments: bool = False,
num_workers: int = 10,
include_images: bool = False,
):
"""
Get events from the calendar.
Expand Down Expand Up @@ -340,14 +391,44 @@ def get_events(
json.dumps(events[:5], indent=2, ensure_ascii=False),
)

if include_comments:
if include_comments or include_images:
logger.warning(
"Exporting comments requires extra TimeTree requests per event and may take "
"much longer or trigger rate limits."
"Exporting comments or images requires extra TimeTree requests per event and may "
"take much longer or trigger rate limits."
)
return self.add_event_activities(
calendar_id,
events,
calendar_users,
include_comments=include_comments,
include_images=include_images,
num_workers=num_workers,
)
return self.add_event_comments(calendar_id, events, calendar_users, num_workers)
return events

def download_image(self, object_key, output_path):
"""Download one TimeTree attachment image to the requested path."""
url = f"https://attachments.timetreeapp.com/{object_key}"
try:
response = self.session.get(url, stream=True, timeout=ATTACHMENT_TIMEOUT)
except Timeout:
logger.warning("Timed out downloading image %s", object_key)
raise
response.raise_for_status()
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
with output_path.open("wb") as output_file:
for chunk in response.iter_content(chunk_size=1024 * 1024):
if chunk:
output_file.write(chunk)
except Timeout:
logger.warning("Timed out downloading image %s", object_key)
output_path.unlink(missing_ok=True)
raise
except Exception:
output_path.unlink(missing_ok=True)
raise

def get_public_events(self, calendar_id: int, calendar_name: str = None):
"""
Get events from a public calendar.
Expand Down
1 change: 1 addition & 0 deletions timetree_exporter/api/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
API_BASEURI = "https://timetreeapp.com/api/v1"
API_V2_BASEURI = "https://timetreeapp.com/api/v2"
API_USER_AGENT = "web/2.1.0/en"
ATTACHMENT_TIMEOUT = (10, 60)
Loading