99
1010namespace OCA \DAV \Search ;
1111
12+ use DateTimeImmutable ;
1213use OCA \DAV \CalDAV \CalDavBackend ;
1314use OCP \IUser ;
1415use OCP \Search \IFilteringProvider ;
1516use OCP \Search \ISearchQuery ;
1617use OCP \Search \SearchResult ;
1718use OCP \Search \SearchResultEntry ;
1819use Sabre \VObject \Component ;
20+ use Sabre \VObject \Component \VCalendar ;
1921use Sabre \VObject \DateTimeParser ;
22+ use Sabre \VObject \InvalidDataException ;
2023use Sabre \VObject \Property ;
2124use Sabre \VObject \Property \ICalendar \DateTime ;
22- use function array_combine ;
23- use function array_fill ;
24- use function array_key_exists ;
25- use function array_map ;
25+ use Sabre \VObject \Reader ;
26+ use function array_push ;
27+ use function array_values ;
2628
2729/**
2830 * Class EventsSearchProvider
@@ -101,6 +103,20 @@ public function search(
101103
102104 /** @var string|null $term */
103105 $ term = $ query ->getFilter ('term ' )?->get();
106+
107+ $ since = $ query ->getFilter ('since ' )?->get();
108+ $ until = $ query ->getFilter ('until ' )?->get();
109+
110+ if ($ since !== null && $ until === null ) {
111+ $ until = new DateTimeImmutable ('now ' , new \DateTimeZone ('Z ' ));
112+ }
113+
114+ /** @var array{start: DateTimeImmutable|null, end: DateTimeImmutable|null} $timeRange */
115+ $ timeRange = [
116+ 'start ' => $ since ,
117+ 'end ' => $ until ,
118+ ];
119+
104120 if ($ term === null ) {
105121 $ searchResults = [];
106122 } else {
@@ -113,18 +129,15 @@ public function search(
113129 [
114130 'limit ' => $ query ->getLimit (),
115131 'offset ' => $ query ->getCursor (),
116- 'timerange ' => [
117- 'start ' => $ query ->getFilter ('since ' )?->get(),
118- 'end ' => $ query ->getFilter ('until ' )?->get(),
119- ],
132+ 'timerange ' => $ timeRange ,
120133 ]
121134 );
122135 }
123136 /** @var IUser|null $person */
124137 $ person = $ query ->getFilter ('person ' )?->get();
125138 $ personDisplayName = $ person ?->getDisplayName();
126139 if ($ personDisplayName !== null ) {
127- $ attendeeSearchResults = $ this ->backend ->searchPrincipalUri (
140+ array_push ( $ searchResults , ... $ this ->backend ->searchPrincipalUri (
128141 $ principalUri ,
129142 $ personDisplayName ,
130143 [self ::COMPONENT_TYPE ],
@@ -133,55 +146,125 @@ public function search(
133146 [
134147 'limit ' => $ query ->getLimit (),
135148 'offset ' => $ query ->getCursor (),
136- 'timerange ' => [
137- 'start ' => $ query ->getFilter ('since ' )?->get(),
138- 'end ' => $ query ->getFilter ('until ' )?->get(),
139- ],
149+ 'timerange ' => $ timeRange ,
140150 ],
141- );
151+ ));
152+ }
142153
143- $ searchResultIndex = array_combine (
144- array_map (fn ($ event ) => $ event ['calendarid ' ] . '- ' . $ event ['uri ' ], $ searchResults ),
145- array_fill (0 , count ($ searchResults ), null ),
146- );
147- foreach ($ attendeeSearchResults as $ attendeeResult ) {
148- if (array_key_exists ($ attendeeResult ['calendarid ' ] . '- ' . $ attendeeResult ['uri ' ], $ searchResultIndex )) {
149- // Duplicate
150- continue ;
151- }
152- $ searchResults [] = $ attendeeResult ;
154+ // Resolve each row to its in-range component (deduplicating events that
155+ // matched both the term and attendee searches, keyed by calendarid-uri, and
156+ // dropping anything that does not resolve to a usable in-range component) and
157+ // format it.
158+ $ formattedResults = [];
159+ foreach ($ searchResults as $ searchResult ) {
160+ $ key = $ searchResult ['calendarid ' ] . '- ' . $ searchResult ['uri ' ];
161+ if (isset ($ formattedResults [$ key ])) {
162+ continue ;
163+ }
164+ $ component = $ this ->resolveComponent ($ searchResult ['calendardata ' ], $ since , $ until );
165+ if ($ component === null ) {
166+ continue ;
153167 }
154- }
155- $ formattedResults = \array_map (function (array $ eventRow ) use ($ calendarsById , $ subscriptionsById ): SearchResultEntry {
156- $ component = $ this ->getPrimaryComponent ($ eventRow ['calendardata ' ], self ::COMPONENT_TYPE );
157- $ title = (string )($ component ->SUMMARY ?? $ this ->l10n ->t ('Untitled event ' ));
158168
159- if ($ eventRow ['calendartype ' ] === CalDavBackend::CALENDAR_TYPE_CALENDAR ) {
160- $ calendar = $ calendarsById [$ eventRow ['calendarid ' ]];
169+ $ title = (string )($ component ->SUMMARY ?? $ this ->l10n ->t ('Untitled event ' ));
170+ if ($ searchResult ['calendartype ' ] === CalDavBackend::CALENDAR_TYPE_CALENDAR ) {
171+ $ calendar = $ calendarsById [$ searchResult ['calendarid ' ]];
161172 } else {
162- $ calendar = $ subscriptionsById [$ eventRow ['calendarid ' ]];
173+ $ calendar = $ subscriptionsById [$ searchResult ['calendarid ' ]];
163174 }
164175 $ subline = $ this ->generateSubline ($ component , $ calendar );
165- $ resourceUrl = $ this ->getDeepLinkToCalendarApp ($ calendar ['principaluri ' ], $ calendar ['uri ' ], $ eventRow ['uri ' ]);
176+ $ resourceUrl = $ this ->getDeepLinkToCalendarApp ($ calendar ['principaluri ' ], $ calendar ['uri ' ], $ searchResult ['uri ' ]);
166177 $ result = new SearchResultEntry ('' , $ title , $ subline , $ resourceUrl , 'icon-calendar-dark ' , false );
167178
168179 $ dtStart = $ component ->DTSTART ;
169-
170180 if ($ dtStart instanceof DateTime) {
171- $ startDateTime = $ dtStart ->getDateTime ()->format ('U ' );
172- $ result ->addAttribute ('createdAt ' , $ startDateTime );
181+ $ result ->addAttribute ('createdAt ' , $ dtStart ->getDateTime ()->format ('U ' ));
173182 }
174183
175- return $ result ;
176- }, $ searchResults );
184+ $ formattedResults [ $ key ] = $ result ;
185+ }
177186
178187 return SearchResult::paginated (
179188 $ this ->getName (),
180- $ formattedResults ,
189+ array_values ( $ formattedResults) ,
181190 $ query ->getCursor () + count ($ formattedResults )
182191 );
183192 }
184193
194+ /**
195+ * Resolve the component to display for a result row.
196+ *
197+ * Parses the calendar data and, when a time range is requested,
198+ * expands it to the in-range occurrence. Returns null to drop the row when the
199+ * data is not a calendar or has no occurrence within since and until.
200+ */
201+ private function resolveComponent (string $ calendarData , ?\DateTimeInterface $ since , ?\DateTimeInterface $ until ): ?Component {
202+ $ document = Reader::read ($ calendarData , Reader::OPTION_FORGIVING );
203+ if (!$ document instanceof VCalendar) {
204+ return null ;
205+ }
206+
207+ if ($ since !== null && $ until !== null ) {
208+ $ document = $ this ->expandInRange ($ document , $ since , $ until );
209+ if ($ document === null ) {
210+ return null ;
211+ }
212+ }
213+
214+ return $ this ->getPrimaryComponent ($ document , self ::COMPONENT_TYPE );
215+ }
216+
217+ /**
218+ * Expand a recurring event into its occurrences within the requested
219+ * [$since, $until] window, converted back from the UTC that expand() forces
220+ * into the event's original timezone.
221+ *
222+ * Returns null when the event has no occurrence in range (recurrence gap) or
223+ * cannot be expanded.
224+ */
225+ private function expandInRange (VCalendar $ vCalendar , \DateTimeInterface $ since , \DateTimeInterface $ until ): ?VCalendar {
226+ // expand() rewrites every occurrence's DTSTART/DTEND to UTC, so remember
227+ // the event's original timezone to display the occurrence in local time.
228+ $ originalTimeZone = null ;
229+ $ baseComponent = $ vCalendar ->getBaseComponent (self ::COMPONENT_TYPE );
230+ if ($ baseComponent !== null && isset ($ baseComponent ->DTSTART ) && $ baseComponent ->DTSTART ->hasTime ()) {
231+ $ originalTimeZone = $ baseComponent ->DTSTART ->getDateTime ()->getTimezone ();
232+ }
233+
234+ try {
235+ $ expanded = $ vCalendar ->expand ($ since , $ until );
236+ } catch (InvalidDataException $ e ) {
237+ return null ;
238+ }
239+
240+ $ occurrences = $ expanded ->select (self ::COMPONENT_TYPE );
241+ if ($ occurrences === []) {
242+ return null ;
243+ }
244+
245+ if ($ originalTimeZone !== null ) {
246+ foreach ($ occurrences as $ occurrence ) {
247+ $ this ->applyTimeZone ($ occurrence , $ originalTimeZone );
248+ }
249+ }
250+
251+ return $ expanded ;
252+ }
253+
254+ /**
255+ * Move the occurrence back into the event's original timezone after expand()
256+ * has rewritten it to UTC, so the rendered time matches the user's local time.
257+ */
258+ private function applyTimeZone (Component $ component , \DateTimeZone $ timeZone ): void {
259+ foreach (['DTSTART ' , 'DTEND ' ] as $ name ) {
260+ if (isset ($ component ->$ name ) && $ component ->$ name ->hasTime ()) {
261+ $ component ->$ name ->setDateTime (
262+ $ component ->$ name ->getDateTime ()->setTimezone ($ timeZone ),
263+ );
264+ }
265+ }
266+ }
267+
185268 protected function getDeepLinkToCalendarApp (
186269 string $ principalUri ,
187270 string $ calendarUri ,
0 commit comments