@@ -12,9 +12,29 @@ internal sealed class CalendarService : IDisposable
1212 private static readonly char [ ] LineSplitSeparators = new [ ] { '\r ' , '\n ' } ;
1313
1414 // HttpClient is intended to be reused; dispose only when this service is disposed (CA1001).
15- private readonly HttpClient httpClient = new ( ) ;
15+ private readonly HttpClient httpClient ;
16+
17+ // Lightweight change validators; we intentionally do NOT cache ICS content or parsed entries.
18+ private string ? lastEtag ;
19+ private DateTimeOffset ? lastModified ;
1620 private bool disposed ;
1721
22+ /// <summary>Initializes a new instance of the <see cref="CalendarService"/> class.</summary>
23+ internal CalendarService ( )
24+ {
25+ this . httpClient = new HttpClient ( ) ;
26+ }
27+
28+ /// <summary>Initializes a new instance of the <see cref="CalendarService"/> class using a custom <see cref="HttpMessageHandler"/>. Internal for test injection; production code uses the default constructor.</summary>
29+ /// <param name="handler">Custom HTTP message handler.</param>
30+ internal CalendarService ( HttpMessageHandler handler )
31+ {
32+ this . httpClient = new HttpClient ( handler , disposeHandler : true ) ;
33+ }
34+
35+ /// <summary>Gets a value indicating whether previously observed change validators (ETag or Last-Modified) are available.</summary>
36+ public bool HasChangeValidators => this . lastEtag != null || this . lastModified != null ;
37+
1838 /// <summary>
1939 /// Fetches and parses calendar entries from the specified ICS URL.
2040 /// </summary>
@@ -66,6 +86,55 @@ public async Task<IReadOnlyList<CalendarEntry>> FetchAsync(Uri calendarIcsUri, C
6686 }
6787 }
6888
89+ /// <summary>
90+ /// Performs a conditional GET using previously observed ETag / Last-Modified validators (if any) and
91+ /// returns parsed entries only when the server indicates the resource changed. On 304 Not Modified an empty list is returned
92+ /// allowing callers to decide whether to reuse prior results. This service purposely does not retain prior entries or ICS text.
93+ /// </summary>
94+ /// <param name="calendarIcsUri">ICS feed URI.</param>
95+ /// <param name="ct">Cancellation token.</param>
96+ /// <returns>List of parsed entries when changed; empty list on 304 or errors.</returns>
97+ public async Task < IReadOnlyList < CalendarEntry > > FetchIfChangedAsync ( Uri calendarIcsUri , CancellationToken ct = default )
98+ {
99+ try
100+ {
101+ using HttpRequestMessage req = new ( HttpMethod . Get , calendarIcsUri ) ;
102+ if ( ! string . IsNullOrEmpty ( this . lastEtag ) )
103+ {
104+ req . Headers . TryAddWithoutValidation ( "If-None-Match" , this . lastEtag ) ;
105+ }
106+
107+ if ( this . lastModified . HasValue )
108+ {
109+ req . Headers . IfModifiedSince = this . lastModified ;
110+ }
111+
112+ using HttpResponseMessage resp = await this . httpClient . SendAsync ( req , HttpCompletionOption . ResponseHeadersRead , ct ) . ConfigureAwait ( false ) ;
113+ if ( resp . StatusCode == System . Net . HttpStatusCode . NotModified )
114+ {
115+ return Array . Empty < CalendarEntry > ( ) ;
116+ }
117+
118+ if ( ! resp . IsSuccessStatusCode )
119+ {
120+ return Array . Empty < CalendarEntry > ( ) ;
121+ }
122+
123+ this . lastEtag = resp . Headers . ETag ? . ToString ( ) ;
124+ this . lastModified = resp . Content . Headers . LastModified ;
125+ string text = await resp . Content . ReadAsStringAsync ( ct ) . ConfigureAwait ( false ) ;
126+ return ParseIcs ( text ) ;
127+ }
128+ catch ( HttpRequestException )
129+ {
130+ return Array . Empty < CalendarEntry > ( ) ;
131+ }
132+ catch ( TaskCanceledException )
133+ {
134+ return Array . Empty < CalendarEntry > ( ) ;
135+ }
136+ }
137+
69138 /// <summary>
70139 /// Similar to <see cref="FetchAsync(Uri, CancellationToken)"/> but propagates failures as exceptions
71140 /// so callers can inspect error messages. Use this when the caller wants to display errors to the user.
@@ -105,6 +174,65 @@ public async Task<IReadOnlyList<CalendarEntry>> FetchWithErrorsAsync(Uri calenda
105174 return ParseIcs ( text ) ;
106175 }
107176
177+ /// <summary>
178+ /// Same as <see cref="FetchWithErrorsAsync"/> but uses conditional validators to potentially avoid downloading the body.
179+ /// Throws only when the server returns an error status for a changed resource; 304 yields an empty list.
180+ /// </summary>
181+ /// <param name="calendarIcsUri">ICS feed URI.</param>
182+ /// <param name="ct">Cancellation token.</param>
183+ /// <returns>Parsed entries when changed; empty list when not modified.</returns>
184+ /// <exception cref="HttpRequestException">If HTTP status is non-success (excluding 304).</exception>
185+ public async Task < IReadOnlyList < CalendarEntry > > FetchIfChangedWithErrorsAsync ( Uri calendarIcsUri , CancellationToken ct = default )
186+ {
187+ using HttpRequestMessage req = new ( HttpMethod . Get , calendarIcsUri ) ;
188+ if ( ! string . IsNullOrEmpty ( this . lastEtag ) )
189+ {
190+ req . Headers . TryAddWithoutValidation ( "If-None-Match" , this . lastEtag ) ;
191+ }
192+
193+ if ( this . lastModified . HasValue )
194+ {
195+ req . Headers . IfModifiedSince = this . lastModified ;
196+ }
197+
198+ using HttpResponseMessage resp = await this . httpClient . SendAsync ( req , HttpCompletionOption . ResponseHeadersRead , ct ) . ConfigureAwait ( false ) ;
199+ if ( resp . StatusCode == System . Net . HttpStatusCode . NotModified )
200+ {
201+ return Array . Empty < CalendarEntry > ( ) ;
202+ }
203+
204+ if ( ! resp . IsSuccessStatusCode )
205+ {
206+ int code = ( int ) resp . StatusCode ;
207+ string reason = resp . ReasonPhrase ?? resp . StatusCode . ToString ( ) ;
208+ string loc = resp . Headers . Location ? . ToString ( ) ?? string . Empty ;
209+ string hint = string . Empty ;
210+ if ( code >= 300 && code < 400 )
211+ {
212+ hint = " Redirected location may require authentication." ;
213+ }
214+ else if ( code == 401 || code == 403 )
215+ {
216+ hint = " Calendar feed appears to require authentication (401/403)." ;
217+ }
218+
219+ string msg = ! string . IsNullOrEmpty ( loc )
220+ ? $ "HTTP { code } { reason } -> { loc } .{ hint } "
221+ : $ "HTTP { code } { reason } .{ hint } ";
222+
223+ throw new HttpRequestException ( msg ) ;
224+ }
225+
226+ this . lastEtag = resp . Headers . ETag ? . ToString ( ) ;
227+ this . lastModified = resp . Content . Headers . LastModified ;
228+ string text = await resp . Content . ReadAsStringAsync ( ct ) . ConfigureAwait ( false ) ;
229+ return ParseIcs ( text ) ;
230+ }
231+
232+ /// <summary>
233+ /// Indicates whether conditional request validators were previously observed (ETag or Last-Modified).
234+ /// </summary>
235+
108236 /// <summary>
109237 /// Disposes the underlying <see cref="HttpClient"/> instance used by this service.
110238 /// </summary>
0 commit comments