Skip to content

Commit cfb87e7

Browse files
committed
comments: add Comment.timestamp
1 parent cab50c5 commit cfb87e7

File tree

5 files changed

+78
-2
lines changed

5 files changed

+78
-2
lines changed

features/cmt-props.feature

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ Feature: Get comment properties
1919
Then comment.initials is the initials of the comment author
2020

2121

22-
@wip
2322
Scenario: Comment.timestamp
2423
Given a Comment object
2524
Then comment.timestamp is the date and time the comment was authored

src/docx/comments.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import datetime as dt
56
from typing import TYPE_CHECKING, Iterator
67

78
from docx.blkcntnr import BlockItemContainer
@@ -71,3 +72,11 @@ def initials(self) -> str | None:
7172
any existing initials from the XML.
7273
"""
7374
return self._comment_elm.initials
75+
76+
@property
77+
def timestamp(self) -> dt.datetime | None:
78+
"""The date and time this comment was authored.
79+
80+
This attribute is optional in the XML, returns |None| if not set.
81+
"""
82+
return self._comment_elm.date

src/docx/oxml/comments.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
from __future__ import annotations
44

5-
from docx.oxml.simpletypes import ST_DecimalNumber, ST_String
5+
import datetime as dt
6+
7+
from docx.oxml.simpletypes import ST_DateTime, ST_DecimalNumber, ST_String
68
from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrMore
79

810

@@ -39,3 +41,6 @@ class CT_Comment(BaseOxmlElement):
3941
initials: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType]
4042
"w:initials", ST_String
4143
)
44+
date: dt.datetime | None = OptionalAttribute( # pyright: ignore[reportAssignmentType]
45+
"w:date", ST_DateTime
46+
)

src/docx/oxml/simpletypes.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from __future__ import annotations
1111

12+
import datetime as dt
1213
from typing import TYPE_CHECKING, Any, Tuple
1314

1415
from docx.exceptions import InvalidXmlError
@@ -213,6 +214,58 @@ def validate(cls, value: Any) -> None:
213214
cls.validate_int_in_range(value, -27273042329600, 27273042316900)
214215

215216

217+
class ST_DateTime(BaseSimpleType):
218+
@classmethod
219+
def convert_from_xml(cls, str_value: str) -> dt.datetime:
220+
"""Convert an xsd:dateTime string to a datetime object."""
221+
222+
def parse_xsd_datetime(dt_str: str) -> dt.datetime:
223+
# -- handle trailing 'Z' (Zulu/UTC), common in Word files --
224+
if dt_str.endswith("Z"):
225+
try:
226+
# -- optional fractional seconds case --
227+
return dt.datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%S.%fZ").replace(
228+
tzinfo=dt.timezone.utc
229+
)
230+
except ValueError:
231+
return dt.datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%SZ").replace(
232+
tzinfo=dt.timezone.utc
233+
)
234+
235+
# -- handles explicit offsets like +00:00, -05:00, or naive datetimes --
236+
try:
237+
return dt.datetime.fromisoformat(dt_str)
238+
except ValueError:
239+
# -- fall-back to parsing as naive datetime (with or without fractional seconds) --
240+
try:
241+
return dt.datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%S.%f")
242+
except ValueError:
243+
return dt.datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%S")
244+
245+
try:
246+
# -- parse anything reasonable, but never raise, just use default epoch time --
247+
return parse_xsd_datetime(str_value)
248+
except Exception:
249+
return dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc)
250+
251+
@classmethod
252+
def convert_to_xml(cls, value: dt.datetime) -> str:
253+
# -- convert naive datetime to timezon-aware assuming local timezone --
254+
if value.tzinfo is None:
255+
value = value.astimezone()
256+
257+
# -- convert to UTC if not already --
258+
value = value.astimezone(dt.timezone.utc)
259+
260+
# -- format with 'Z' suffix for UTC --
261+
return value.strftime("%Y-%m-%dT%H:%M:%SZ")
262+
263+
@classmethod
264+
def validate(cls, value: Any) -> None:
265+
if not isinstance(value, dt.datetime):
266+
raise TypeError("only a datetime.datetime object may be assigned, got '%s'" % value)
267+
268+
216269
class ST_DecimalNumber(XsdInt):
217270
pass
218271

tests/test_comments.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from __future__ import annotations
66

7+
import datetime as dt
78
from typing import cast
89

910
import pytest
@@ -113,6 +114,15 @@ def it_knows_the_initials_of_its_author(self, comments_part_: Mock):
113114

114115
assert comment.initials == "SJC"
115116

117+
def it_knows_the_date_and_time_it_was_authored(self, comments_part_: Mock):
118+
comment_elm = cast(
119+
CT_Comment,
120+
element("w:comment{w:id=42,w:date=2023-10-01T12:34:56Z}"),
121+
)
122+
comment = Comment(comment_elm, comments_part_)
123+
124+
assert comment.timestamp == dt.datetime(2023, 10, 1, 12, 34, 56, tzinfo=dt.timezone.utc)
125+
116126
# -- fixtures --------------------------------------------------------------------------------
117127

118128
@pytest.fixture

0 commit comments

Comments
 (0)