From 9a1572468da9975154f636b1bf243345b01e9cb0 Mon Sep 17 00:00:00 2001 From: Makiko Ohashi Date: Thu, 20 Aug 2026 12:34:01 +0900 Subject: [PATCH 1/2] feat: export private event images --- README.md | 11 ++++ tests/test_calendar.py | 39 +++++++++++ tests/test_main.py | 46 +++++++++++++ timetree_exporter/__main__.py | 1 + timetree_exporter/api/calendar.py | 106 ++++++++++++++++++++++++------ timetree_exporter/calendar.py | 11 +++- timetree_exporter/cli.py | 8 +++ timetree_exporter/exporter.py | 97 ++++++++++++++++++++++++++- 8 files changed, 296 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index e904d8b..75f9a55 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/tests/test_calendar.py b/tests/test_calendar.py index b13da39..b05c20e 100644 --- a/tests/test_calendar.py +++ b/tests/test_calendar.py @@ -234,6 +234,45 @@ 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_get_events_does_not_fetch_comments_by_default(): """Private event exports should avoid per-event activity calls by default.""" calendar = TimeTreeCalendar("dummy-session-id") diff --git a/tests/test_main.py b/tests/test_main.py index 52e17fb..4dfcbfb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -38,6 +38,7 @@ 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, @@ -45,14 +46,21 @@ def get_events( 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 @@ -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( diff --git a/timetree_exporter/__main__.py b/timetree_exporter/__main__.py index a1881c7..18e1d7e 100644 --- a/timetree_exporter/__main__.py +++ b/timetree_exporter/__main__.py @@ -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, ) diff --git a/timetree_exporter/api/calendar.py b/timetree_exporter/api/calendar.py index f68151b..0d1eff5 100644 --- a/timetree_exporter/api/calendar.py +++ b/timetree_exporter/api/calendar.py @@ -240,6 +240,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, @@ -258,19 +279,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")} @@ -285,10 +309,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 } @@ -297,14 +320,36 @@ 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, @@ -312,6 +357,7 @@ def get_events( calendar_users=None, include_comments: bool = False, num_workers: int = 10, + include_images: bool = False, ): """ Get events from the calendar. @@ -340,14 +386,36 @@ 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}" + response = self.session.get(url, stream=True) + 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 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. diff --git a/timetree_exporter/calendar.py b/timetree_exporter/calendar.py index ecc7a3e..90611f4 100644 --- a/timetree_exporter/calendar.py +++ b/timetree_exporter/calendar.py @@ -14,6 +14,7 @@ def get_events( calendar_users=None, include_comments=False, num_workers=10, + include_images=False, ): """Return events for a calendar.""" @@ -23,6 +24,9 @@ def get_public_events(self, calendar_id, calendar_name): def get_public_labels(self, calendar_id): """Return labels for a public calendar.""" + def download_image(self, object_key, output_path): + """Download a private event image.""" + def get_labels(self, calendar_id): """Return labels for a calendar.""" @@ -54,7 +58,7 @@ def is_public(self): """Return whether this calendar should use the public calendar API.""" return self.metadata.get("public", False) - def get_events(self, include_comments=False, num_workers=10): + def get_events(self, include_comments=False, num_workers=10, include_images=False): """Return events for this calendar.""" if self.is_public: return self.api.get_public_events(self.id, self.name) @@ -63,9 +67,14 @@ def get_events(self, include_comments=False, num_workers=10): self.name, self.metadata.get("calendar_users"), include_comments=include_comments, + include_images=include_images, num_workers=num_workers, ) + def download_image(self, object_key, output_path): + """Download a private event image through the calendar API.""" + return self.api.download_image(object_key, output_path) + def get_labels(self): """Return labels for this calendar.""" if self.is_public: diff --git a/timetree_exporter/cli.py b/timetree_exporter/cli.py index 90c12a4..41409d2 100644 --- a/timetree_exporter/cli.py +++ b/timetree_exporter/cli.py @@ -89,6 +89,14 @@ def parse_args(): ), action="store_true", ) + parser.add_argument( + "--include-images", + help=( + "Export images attached to private event activities. This makes extra TimeTree " + "requests per event and may take much longer." + ), + action="store_true", + ) parser.add_argument( "--num-workers", type=int, diff --git a/timetree_exporter/exporter.py b/timetree_exporter/exporter.py index a0fe343..256bf51 100644 --- a/timetree_exporter/exporter.py +++ b/timetree_exporter/exporter.py @@ -1,10 +1,14 @@ """Build and write iCalendar exports from TimeTree events.""" +import json import logging import re from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime from importlib.metadata import version -from pathlib import Path +from pathlib import Path, PurePosixPath +from zoneinfo import ZoneInfo from icalendar import Calendar as ICalendar @@ -18,21 +22,33 @@ class Exporter: """Export a selected TimeTree calendar to one or more iCalendar files.""" def __init__( - self, calendar, output, split_by_label=False, include_comments=False, num_workers=10 + self, + calendar, + output, + split_by_label=False, + include_comments=False, + num_workers=10, + include_images=False, ): self.calendar = calendar self.output = output self.split_by_label = split_by_label self.include_comments = include_comments + self.include_images = include_images self.num_workers = num_workers def export(self): """Fetch labels and events, then write the configured iCalendar output.""" events = self.calendar.get_events( - include_comments=self.include_comments, num_workers=self.num_workers + include_comments=self.include_comments, + include_images=self.include_images, + num_workers=self.num_workers, ) logger.info("Found %d events", len(events)) + if self.include_images and not self.calendar.is_public: + download_event_images(self.calendar, events, self.output, self.num_workers) + labels = self.calendar.get_labels() if self.calendar.is_public and not labels: labels = public_labels_from_events(events) @@ -77,6 +93,81 @@ def write_calendar(cal, output_path: str | Path): logger.info("The .ics calendar file is saved to %s", path.resolve()) +def _image_path(output_path, event_uuid, object_key): + """Return a stable image path below the ICS output directory.""" + if ( + not isinstance(event_uuid, str) + or not event_uuid + or event_uuid in {".", ".."} + or "/" in event_uuid + or "\\" in event_uuid + ): + raise ValueError(f"Invalid event UUID: {event_uuid}") + key_path = PurePosixPath(object_key) + if key_path.is_absolute() or ".." in key_path.parts: + raise ValueError(f"Invalid image object key: {object_key}") + return Path(output_path).parent / "timetree_images" / event_uuid / Path(*key_path.parts) + + +def _event_start_date(event): + """Return an event's local start date as an ISO string.""" + timestamp = event.get("start_at") + if timestamp is None: + return None + timezone = ZoneInfo(event.get("start_timezone") or "UTC") + return datetime.fromtimestamp(timestamp / 1000, timezone).date().isoformat() + + +def download_event_images(calendar, events, output, num_workers): + """Download event images and write their event mapping manifest.""" + output_path = Path(output) + manifest = [] + tasks = [] + + for event in events: + event_uuid = event.get("uuid") + for image in event.get("_image_attachments", []): + object_key = image["object_key"] + try: + image_path = _image_path(output_path, event_uuid, object_key) + except ValueError: + logger.warning("Skipping invalid image path for event %s", event_uuid) + continue + entry = { + "event_uuid": event_uuid, + "title": event.get("title"), + "start_date": _event_start_date(event), + "image_path": image_path.relative_to(output_path.parent).as_posix(), + "object_key": object_key, + } + if not image_path.is_file(): + tasks.append((object_key, image_path, entry)) + else: + manifest.append(entry) + + with ThreadPoolExecutor(max_workers=max(1, num_workers)) as executor: + futures = { + executor.submit(calendar.download_image, object_key, image_path): entry + for object_key, image_path, entry in tasks + } + for future in as_completed(futures): + entry = futures[future] + try: + future.result() + manifest.append(entry) + logger.info("Downloaded image to %s", entry["image_path"]) + except Exception as exc: + logger.warning("Failed to download image to %s: %s", entry["image_path"], exc) + + manifest.sort(key=lambda entry: (entry["event_uuid"], entry["object_key"])) + + manifest_path = output_path.parent / "timetree_images.json" + manifest_path.write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + logger.info("The image manifest is saved to %s", manifest_path.resolve()) + + def public_labels_from_events(events): """Build label metadata from public calendar event payloads.""" labels = {} From 48c884e977f9087dd9220fa536a6c8476b78b554 Mon Sep 17 00:00:00 2001 From: Makiko Ohashi Date: Thu, 20 Aug 2026 19:37:05 +0900 Subject: [PATCH 2/2] fix: add timeout for attachment downloads --- tests/test_calendar.py | 29 +++++++++++++++++++++++++++++ timetree_exporter/api/calendar.py | 21 +++++++++++++++++---- timetree_exporter/api/const.py | 1 + 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/tests/test_calendar.py b/tests/test_calendar.py index b05c20e..0962b23 100644 --- a/tests/test_calendar.py +++ b/tests/test_calendar.py @@ -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 @@ -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) @@ -273,6 +287,21 @@ def test_get_events_fetches_comments_and_images_from_one_activity_request(): 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") diff --git a/timetree_exporter/api/calendar.py b/timetree_exporter/api/calendar.py index 0d1eff5..600013d 100644 --- a/timetree_exporter/api/calendar.py +++ b/timetree_exporter/api/calendar.py @@ -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__) @@ -404,7 +409,11 @@ def get_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}" - response = self.session.get(url, stream=True) + 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: @@ -412,6 +421,10 @@ def download_image(self, object_key, output_path): 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 diff --git a/timetree_exporter/api/const.py b/timetree_exporter/api/const.py index e2253b3..5a26fac 100644 --- a/timetree_exporter/api/const.py +++ b/timetree_exporter/api/const.py @@ -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)