Skip to content

Commit 7f7ea00

Browse files
committed
Include attachments in TSV and JSON formatted output
1 parent 2d25ec0 commit 7f7ea00

2 files changed

Lines changed: 234 additions & 2 deletions

File tree

gcalcli/details.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,52 @@ def _get(cls, event):
366366
return ACTION_DEFAULT
367367

368368

369+
class Attachments(Handler):
370+
"""Handler for event attachments."""
371+
372+
fieldnames = ['attachments']
373+
374+
ATTACHMENT_PROPS = OrderedDict([('attachment_title', 'title'),
375+
('attachment_url', 'fileUrl')])
376+
377+
@classmethod
378+
def fieldnames_data(cls):
379+
return list(cls.ATTACHMENT_PROPS.keys())
380+
381+
@classmethod
382+
def get(cls, event):
383+
if 'attachments' not in event:
384+
return ['']
385+
386+
# For TSV output, use form feed (\f) to separate multiple attachments
387+
# and pipe (|) to separate title from URL within each attachment.
388+
# This follows the pattern suggested in issue #829 for handling
389+
# list fields in TSV format.
390+
# Format: "title1|url1\ftitle2|url2"
391+
attachment_strs = []
392+
for attachment in event['attachments']:
393+
title = attachment.get('title', '').strip()
394+
url = attachment.get('fileUrl', '').strip()
395+
# Escape any existing form feeds in the data
396+
title = title.replace('\f', r'\f')
397+
url = url.replace('\f', r'\f')
398+
attachment_strs.append(f"{title}|{url}")
399+
400+
return ['\f'.join(attachment_strs)]
401+
402+
@classmethod
403+
def data(cls, event):
404+
attachments = event.get('attachments', [])
405+
return [dict(zip(cls.ATTACHMENT_PROPS.keys(),
406+
[attachment.get(prop, '') for prop in cls.ATTACHMENT_PROPS.values()]))
407+
for attachment in attachments]
408+
409+
@classmethod
410+
def patch(cls, cal, event, fieldname, value):
411+
# Attachments are read-only for now
412+
raise ReadonlyCheckError(fieldname, cls.get(event), value)
413+
414+
369415
HANDLERS = OrderedDict([('id', ID),
370416
('time', Time),
371417
('length', Length),
@@ -377,8 +423,9 @@ def _get(cls, event):
377423
('calendar', Calendar),
378424
('email', Email),
379425
('attendees', Attendees),
426+
('attachments', Attachments),
380427
('action', Action)])
381-
HANDLERS_READONLY = {Url, Calendar}
428+
HANDLERS_READONLY = {Url, Calendar, Attachments}
382429

383430
FIELD_HANDLERS = dict(chain.from_iterable(
384431
(((fieldname, handler)
@@ -390,7 +437,7 @@ def _get(cls, event):
390437
in FIELD_HANDLERS.items()
391438
if handler in HANDLERS_READONLY)
392439

393-
_DETAILS_WITHOUT_HANDLERS = ['reminders', 'attachments', 'end']
440+
_DETAILS_WITHOUT_HANDLERS = ['reminders', 'end']
394441

395442
DETAILS = list(HANDLERS.keys()) + _DETAILS_WITHOUT_HANDLERS
396443
DETAILS_DEFAULT = {'time', 'title'}

tests/test_details.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""Tests for detail handlers."""
2+
3+
import pytest
4+
from gcalcli.details import (
5+
Attachments,
6+
Attendees,
7+
Title,
8+
Conference,
9+
)
10+
11+
12+
class TestAttachmentsHandler:
13+
"""Tests for Attachments handler."""
14+
15+
def test_get_with_no_attachments(self):
16+
"""Test get() returns empty string for events without attachments."""
17+
event = {}
18+
result = Attachments.get(event)
19+
assert result == ['']
20+
21+
def test_get_with_single_attachment(self):
22+
"""Test get() returns properly formatted string for single attachment."""
23+
event = {
24+
'attachments': [
25+
{
26+
'title': 'Notes by Gemini',
27+
'fileUrl': 'https://docs.google.com/document/d/123/edit'
28+
}
29+
]
30+
}
31+
result = Attachments.get(event)
32+
assert result == ['Notes by Gemini|https://docs.google.com/document/d/123/edit']
33+
34+
def test_get_with_multiple_attachments(self):
35+
"""Test get() returns properly formatted string for multiple attachments."""
36+
event = {
37+
'attachments': [
38+
{
39+
'title': 'Document 1',
40+
'fileUrl': 'https://docs.google.com/document/d/123/edit'
41+
},
42+
{
43+
'title': 'Document 2',
44+
'fileUrl': 'https://docs.google.com/document/d/456/edit'
45+
}
46+
]
47+
}
48+
result = Attachments.get(event)
49+
expected = 'Document 1|https://docs.google.com/document/d/123/edit\fDocument 2|https://docs.google.com/document/d/456/edit'
50+
assert result == [expected]
51+
52+
def test_get_with_missing_fields(self):
53+
"""Test get() handles missing title/fileUrl fields gracefully."""
54+
event = {
55+
'attachments': [
56+
{
57+
'title': 'Document 1'
58+
# missing fileUrl
59+
},
60+
{
61+
'fileUrl': 'https://docs.google.com/document/d/456/edit'
62+
# missing title
63+
}
64+
]
65+
}
66+
result = Attachments.get(event)
67+
expected = 'Document 1|\f|https://docs.google.com/document/d/456/edit'
68+
assert result == [expected]
69+
70+
def test_data_with_no_attachments(self):
71+
"""Test data() returns empty list for events without attachments."""
72+
event = {}
73+
result = Attachments.data(event)
74+
assert result == []
75+
76+
def test_data_with_single_attachment(self):
77+
"""Test data() returns properly formatted dict for single attachment."""
78+
event = {
79+
'attachments': [
80+
{
81+
'title': 'Notes by Gemini',
82+
'fileUrl': 'https://docs.google.com/document/d/123/edit'
83+
}
84+
]
85+
}
86+
result = Attachments.data(event)
87+
expected = [
88+
{
89+
'attachment_title': 'Notes by Gemini',
90+
'attachment_url': 'https://docs.google.com/document/d/123/edit'
91+
}
92+
]
93+
assert result == expected
94+
95+
def test_data_with_multiple_attachments(self):
96+
"""Test data() returns properly formatted dict list for multiple attachments."""
97+
event = {
98+
'attachments': [
99+
{
100+
'title': 'Document 1',
101+
'fileUrl': 'https://docs.google.com/document/d/123/edit'
102+
},
103+
{
104+
'title': 'Document 2',
105+
'fileUrl': 'https://docs.google.com/document/d/456/edit'
106+
}
107+
]
108+
}
109+
result = Attachments.data(event)
110+
expected = [
111+
{
112+
'attachment_title': 'Document 1',
113+
'attachment_url': 'https://docs.google.com/document/d/123/edit'
114+
},
115+
{
116+
'attachment_title': 'Document 2',
117+
'attachment_url': 'https://docs.google.com/document/d/456/edit'
118+
}
119+
]
120+
assert result == expected
121+
122+
def test_data_with_missing_fields(self):
123+
"""Test data() handles missing title/fileUrl fields gracefully."""
124+
event = {
125+
'attachments': [
126+
{
127+
'title': 'Document 1'
128+
# missing fileUrl
129+
},
130+
{
131+
'fileUrl': 'https://docs.google.com/document/d/456/edit'
132+
# missing title
133+
}
134+
]
135+
}
136+
result = Attachments.data(event)
137+
expected = [
138+
{
139+
'attachment_title': 'Document 1',
140+
'attachment_url': ''
141+
},
142+
{
143+
'attachment_title': '',
144+
'attachment_url': 'https://docs.google.com/document/d/456/edit'
145+
}
146+
]
147+
assert result == expected
148+
149+
def test_get_with_semicolons(self):
150+
"""Test get() handles semicolons in titles and URLs correctly."""
151+
event = {
152+
'attachments': [
153+
{
154+
'title': 'Meeting Notes; Q4 2025',
155+
'fileUrl': 'https://example.com/doc;jsessionid=ABC123'
156+
},
157+
{
158+
'title': 'Agenda',
159+
'fileUrl': 'https://docs.google.com/document/d/456/edit'
160+
}
161+
]
162+
}
163+
result = Attachments.get(event)
164+
# Semicolons should be preserved, form feed used as separator
165+
expected = 'Meeting Notes; Q4 2025|https://example.com/doc;jsessionid=ABC123\fAgenda|https://docs.google.com/document/d/456/edit'
166+
assert result == [expected]
167+
168+
def test_get_with_form_feed_in_data(self):
169+
"""Test get() escapes existing form feed characters in data."""
170+
event = {
171+
'attachments': [
172+
{
173+
'title': 'Document\fwith\fformfeeds',
174+
'fileUrl': 'https://example.com/doc'
175+
}
176+
]
177+
}
178+
result = Attachments.get(event)
179+
# Form feeds in data should be escaped as r'\f'
180+
expected = r'Document\fwith\fformfeeds|https://example.com/doc'
181+
assert result == [expected]
182+
183+
def test_fieldnames(self):
184+
"""Test fieldnames are correctly defined."""
185+
assert Attachments.fieldnames == ['attachments']

0 commit comments

Comments
 (0)