001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.console.widgets; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.time.*; 022import java.time.format.*; 023import java.util.*; 024 025import org.apache.juneau.commons.http.*; 026 027/** 028 * A single calendar event — also the element type of the per-month event GET envelope's {@code events}. 029 * 030 * <p> 031 * Bean-only ({@code juneau-console-widgets}): the emitter and runtime live in {@code juneau-console-views}. 032 * Titles, tooltips, and labels are painted with {@code textContent} by the runtime, never as markup. 033 * 034 * <h5 class='section'>{@code end} is layout-significant, with split inclusivity:</h5> 035 * <ul> 036 * <li><b>All-day (date-only {@code end}):</b> <b>inclusive</b>. {@code start=2026-03-02}, {@code end=2026-03-04} 037 * occupies <b>three</b> day cells. 038 * <li><b>Timed (date-time {@code end}):</b> <b>exclusive</b>. {@code start=…T09:00}, {@code end=…T10:00} occupies 039 * <code>[09:00, 10:00)</code> — the start day only. A timed event crossing midnight is a spanning bar 040 * using the exclusive {@code end}, never intra-day chips on each crossed day. 041 * <li><b>Omitted {@code end}:</b> start-only, and <b>valid</b>. An all-day event occupies the start day cell; a 042 * timed event occupies the start instant as a zero-width chip on the start day (never a spanning bar). 043 * <li>An all-day {@code end} equal to {@code start} is a single-day event; a <b>timed</b> {@code end} equal to 044 * {@code start} is zero-duration and is rejected, as is any {@code end} before {@code start}. 045 * </ul> 046 * 047 * <h5 class='section'>All-day agreement and the malformed set:</h5> 048 * <p> 049 * Agreement between {@code allDay} and the shape of {@code end} is decided by {@link #effectiveAllDay()}, never by 050 * the raw {@link #allDay} field, so a <jk>null</jk> {@code allDay} paired with matching {@code start}/{@code end} 051 * shapes stays valid. An event is <b>malformed</b> for exactly these reasons — and no others: 052 * <ol> 053 * <li>a missing/blank {@link #id}, {@link #title}, or {@link #start}; 054 * <li>an unparseable {@code start}/{@code end} civil date, or an {@code end} that is not after (all-day: not on or 055 * after) {@code start}; 056 * <li>a declared {@code allDay}/{@code end} disagreement — {@code allDay == } <jk>true</jk> with a date-time 057 * {@code end}, or {@code allDay == } <jk>false</jk> with a date-only {@code end}; 058 * <li>mixed {@code start}/{@code end} shapes with a <jk>null</jk> {@code allDay}. 059 * </ol> 060 * <p> 061 * A malformed event is <b>dropped</b> by {@link CalendarDef#validate()} and by a per-month GET handler that filters 062 * through {@link #retainWellFormed(Collection)} — the same rule on both paths. Neither fails the whole 063 * definition nor the whole month payload. An omitted {@code end} is <b>not</b> malformed. 064 * 065 * <h5 class='section'>Timezone contract:</h5> 066 * <p> 067 * {@code start}/{@code end} are interpreted as an ISO-8601 local date ({@code yyyy-MM-dd}) or local date-time 068 * ({@code yyyy-MM-ddTHH:mm}). A trailing offset or {@code Z} is <b>fail-soft ignored, never rejected</b> — a 069 * seeded {@code 2026-03-02T09:00:00Z} keeps validating and keeps rendering at 09:00 in the server's injected clock 070 * zone. The same parse applies on the seed path and on the per-month GET path. 071 * 072 * @since 10.0.0 073 */ 074@SuppressWarnings({ 075 "java:S1845" // Fluent-builder setters intentionally mirror field names (Juneau DSL convention). 076}) 077public class CalendarEvent { 078 079 /** Stable id; required, non-blank. Used for de-duplication and coalesce keys. */ 080 public String id; 081 082 /** Required, non-blank display title; painted with {@code textContent}. */ 083 public String title; 084 085 /** Required ISO-8601 date ({@code yyyy-MM-dd}) or date-time; the civil date drives chip placement. */ 086 public String start; 087 088 /** 089 * Optional ISO-8601 date/date-time bound. Layout-significant: <b>inclusive</b> for an all-day (date-only) 090 * value, <b>exclusive</b> for a timed (date-time) value. Omitting it is valid and means start-only. 091 */ 092 public String end; 093 094 /** Optional all-day flag; defaults to <jk>true</jk> when {@code start} is a date with no time (see {@link #effectiveAllDay()}). */ 095 public Boolean allDay; 096 097 /** Optional category id; must reference a declared {@link EventCategory} for a seed event; unknown wire ids paint {@code NEUTRAL}. */ 098 public String categoryId; 099 100 /** Optional same-origin <b>document</b> URL ({@link SafePathTemplate#isSafeDocumentUrl(String)} semantics). */ 101 public String href; 102 103 /** Optional tooltip; painted with {@code textContent}. */ 104 public String tooltip; 105 106 /** 107 * Creates an empty event. 108 * 109 * @return A new {@link CalendarEvent}. 110 */ 111 public static CalendarEvent create() { 112 return new CalendarEvent(); 113 } 114 115 /** 116 * Sets the stable id. 117 * 118 * @param value The id. Must not be <jk>null</jk> or blank. 119 * @return This object. 120 */ 121 public CalendarEvent id(String value) { 122 id = value; 123 return this; 124 } 125 126 /** 127 * Sets the display title. 128 * 129 * @param value The title. Must not be <jk>null</jk> or blank. 130 * @return This object. 131 */ 132 public CalendarEvent title(String value) { 133 title = value; 134 return this; 135 } 136 137 /** 138 * Sets the ISO-8601 start. 139 * 140 * @param value A {@code yyyy-MM-dd} date or ISO date-time. Must not be <jk>null</jk>. 141 * @return This object. 142 */ 143 public CalendarEvent start(String value) { 144 start = value; 145 return this; 146 } 147 148 /** 149 * Sets the optional ISO-8601 end. 150 * 151 * @param value A {@code yyyy-MM-dd} date or ISO date-time; must be {@code >= start} when present. 152 * @return This object. 153 */ 154 public CalendarEvent end(String value) { 155 end = value; 156 return this; 157 } 158 159 /** 160 * Sets the all-day flag. 161 * 162 * @param value The all-day flag. 163 * @return This object. 164 */ 165 public CalendarEvent allDay(boolean value) { 166 allDay = value; 167 return this; 168 } 169 170 /** 171 * Sets the category id. 172 * 173 * @param value The category id. 174 * @return This object. 175 */ 176 public CalendarEvent categoryId(String value) { 177 categoryId = value; 178 return this; 179 } 180 181 /** 182 * Sets the same-origin document href. 183 * 184 * @param value The href. 185 * @return This object. 186 */ 187 public CalendarEvent href(String value) { 188 href = value; 189 return this; 190 } 191 192 /** 193 * Sets the tooltip. 194 * 195 * @param value The tooltip. 196 * @return This object. 197 */ 198 public CalendarEvent tooltip(String value) { 199 tooltip = value; 200 return this; 201 } 202 203 /** 204 * The effective all-day flag: the declared {@link #allDay} when set, otherwise <jk>true</jk> when {@code start} 205 * is a date with no time component. 206 * 207 * @return The effective all-day flag. 208 */ 209 public boolean effectiveAllDay() { 210 if (allDay != null) 211 return allDay; 212 return start != null && !start.contains("T"); 213 } 214 215 /** 216 * The civil date the chip paints on — the {@code yyyy-MM-dd} prefix of {@code start} (a date-time 217 * {@code start} is coerced to its leading civil date; a trailing offset is ignored). 218 * 219 * @return The civil start date. 220 * @throws IllegalArgumentException If {@code start} is null or not a parseable ISO date/date-time. 221 */ 222 public LocalDate civilStart() { 223 return parseCivil(start, "start"); 224 } 225 226 /** 227 * The civil date of {@code end}, or <jk>null</jk> when {@code end} is omitted. 228 * 229 * <p> 230 * This is the raw civil date of the bound, <b>before</b> inclusivity is applied — use {@link #lastDay()} 231 * for the last day cell the event actually occupies. 232 * 233 * @return The civil end date, or <jk>null</jk> when {@code end} is omitted or blank. 234 * @throws IllegalArgumentException If {@code end} is present but not a parseable ISO date/date-time. 235 */ 236 public LocalDate civilEnd() { 237 return hasEnd() ? parseCivil(end, "end") : null; 238 } 239 240 /** 241 * The last day cell this event occupies, applying the split inclusivity rule (see the class javadoc). 242 * 243 * <p> 244 * Equals {@link #civilStart()} when {@code end} is omitted. For an all-day event the date-only {@code end} is 245 * inclusive; for a timed event the date-time {@code end} is exclusive, so an end of exactly midnight lands on 246 * the previous day. Never earlier than {@link #civilStart()}. 247 * 248 * @return The inclusive last occupied day. 249 * @throws IllegalArgumentException If {@code start}/{@code end} are not parseable. 250 */ 251 public LocalDate lastDay() { 252 var startDate = civilStart(); 253 if (!hasEnd()) 254 return startDate; 255 var endDate = parseCivil(end, "end"); 256 if (effectiveAllDay()) 257 return endDate.isBefore(startDate) ? startDate : endDate; 258 // Exclusive bound: the last occupied instant is one nanosecond before end, so a midnight end stays on the 259 // previous day. An unparseable time degrades to midnight rather than rejecting the event. 260 var last = endDate.atTime(civilTimeOrMidnight(end)).minusNanos(1).toLocalDate(); 261 return last.isBefore(startDate) ? startDate : last; 262 } 263 264 /** 265 * Whether this event covers more than one day cell and therefore renders as a spanning bar rather than a chip. 266 * 267 * @return <jk>true</jk> if {@link #lastDay()} is after {@link #civilStart()}. 268 * @throws IllegalArgumentException If {@code start}/{@code end} are not parseable. 269 */ 270 public boolean spanning() { 271 return lastDay().isAfter(civilStart()); 272 } 273 274 /** 275 * The leading {@code HH:mm} time label a timed chip renders, or <jk>null</jk> for an all-day event or an 276 * unparseable time. 277 * 278 * @return The {@code HH:mm} label, or <jk>null</jk>. 279 */ 280 public String startTimeLabel() { 281 if (effectiveAllDay()) 282 return null; 283 var t = civilTime(start); 284 return t == null ? null : "%02d:%02d".formatted(t.getHour(), t.getMinute()); 285 } 286 287 /** 288 * The reason this event is malformed, or <jk>null</jk> when it is well-formed. 289 * 290 * <p> 291 * This is the single drop predicate shared by {@link CalendarDef#validate()} (the seed path) and by 292 * {@link #retainWellFormed(Collection)} (the per-month GET path); see the class javadoc for the complete, 293 * closed list of malformed reasons. It is a pure shape check: an unknown {@code categoryId}, an unsafe 294 * {@code href}, and an off-month {@code start} are author errors reported by {@link #validate(Set,Integer,Integer)}, 295 * not drop reasons. 296 * 297 * @return A human-readable reason, or <jk>null</jk> when this event is well-formed. 298 */ 299 public String malformedReason() { 300 if (ib(id)) 301 return "id must not be null or blank"; 302 if (ib(title)) 303 return "title must not be null or blank"; 304 if (civilOrNull(start) == null) 305 return "start is not a valid ISO date or date-time: " + start; 306 if (!hasEnd()) 307 return null; // Omitted end is start-only, and valid. 308 var startDate = civilOrNull(start); 309 var endDate = civilOrNull(end); 310 if (endDate == null) 311 return "end is not a valid ISO date or date-time: " + end; 312 if (effectiveAllDay() != isDateOnly(end)) 313 return "end shape disagrees with the effective all-day flag: " + end; 314 if (effectiveAllDay()) 315 return endDate.isBefore(startDate) ? "end must be >= start" : null; 316 var startAt = startDate.atTime(civilTimeOrMidnight(start)); 317 var endAt = endDate.atTime(civilTimeOrMidnight(end)); 318 // Exclusive end: a timed [t, t) interval is empty, so equality is rejected as well as inversion. 319 return endAt.isAfter(startAt) ? null : "timed end must be after start"; 320 } 321 322 /** 323 * Returns the well-formed events of the given collection, dropping the malformed ones and continuing. 324 * 325 * <p> 326 * A per-month GET handler filters its payload through this before serialization so the dynamic path drops 327 * exactly what {@link CalendarDef#validate()} drops on the seed path. A <jk>null</jk> element is dropped too. 328 * 329 * @param values The events to filter. May be <jk>null</jk>. 330 * @return A new mutable list of the well-formed events, in the original order. Never <jk>null</jk>. 331 */ 332 public static List<CalendarEvent> retainWellFormed(Collection<CalendarEvent> values) { 333 var out = new ArrayList<CalendarEvent>(); 334 if (values != null) 335 for (var e : values) 336 if (e != null && e.malformedReason() == null) 337 out.add(e); 338 return out; 339 } 340 341 /** 342 * Fail-closed shape check for a seed event, reused by {@link CalendarDef#validate()} and by an endpoint handler 343 * that wants to validate its own payload before serialization. 344 * 345 * <p> 346 * Throws for a malformed event (see {@link #malformedReason()}) <b>and</b> for the author errors a seed 347 * definition must not contain: an undeclared {@code categoryId}, a cross-origin {@code href}, and a 348 * {@code start} outside the rendered window. Callers that want the drop-and-continue posture of the seed and 349 * per-month GET paths use {@link #malformedReason()}/{@link #retainWellFormed(Collection)} instead. 350 * 351 * @param knownCategoryIds The declared category ids; a set {@code categoryId} must be a member. May be 352 * <jk>null</jk> (then any {@code categoryId} fails). 353 * @param initialYear The initial year the seed month is rendered for, or <jk>null</jk> to skip the off-month 354 * check. 355 * @param initialMonth The 1-based initial month, or <jk>null</jk> to skip the off-month check. 356 * @throws IllegalArgumentException If this event is not well-formed. 357 */ 358 public void validate(Set<String> knownCategoryIds, Integer initialYear, Integer initialMonth) { 359 var bad = malformedReason(); 360 if (bad != null) 361 throw iaex("CalendarEvent '%s' is malformed: %s", id, bad); 362 var startDate = civilStart(); 363 if (inb(categoryId) 364 && (knownCategoryIds == null || !knownCategoryIds.contains(categoryId))) 365 throw iaex("CalendarEvent '%s' categoryId '%s' is not a declared category.", id, categoryId); 366 if (inb(href) && !SafePathTemplate.isSafeDocumentUrl(href)) 367 throw iaex("CalendarEvent '%s' href must be a same-origin document URL: %s", id, href); 368 if (initialYear != null && initialMonth != null) { 369 // Now that end is layout-significant, "in the rendered month" means the occupied range intersects it, 370 // not that start falls inside it: a span beginning in the previous month is genuinely visible here. 371 var monthStart = LocalDate.of(initialYear, initialMonth, 1); 372 var monthEnd = monthStart.plusMonths(1).minusDays(1); 373 if (lastDay().isBefore(monthStart) || startDate.isAfter(monthEnd)) 374 throw iaex("CalendarEvent '%s' start %s is outside the initial month %d-%02d.", id, start, initialYear, 375 initialMonth); 376 } 377 } 378 379 /** Whether {@code end} carries a value at all (an omitted or blank {@code end} means start-only). */ 380 private boolean hasEnd() { 381 return inb(end); 382 } 383 384 /** Whether an ISO value is date-only ({@code yyyy-MM-dd}) rather than a date-time. */ 385 private static boolean isDateOnly(String value) { 386 return value.length() <= 10; // only reached with a non-blank end (see hasEnd()) 387 } 388 389 /** Parses the civil ({@code yyyy-MM-dd}) portion of an ISO date/date-time; never {@code Date.parse}-style zone shifting. */ 390 private LocalDate parseCivil(String value, String field) { 391 var d = civilOrNull(value); 392 if (d == null) 393 throw iaex("CalendarEvent '%s' %s is not a valid ISO date or date-time: %s", id, field, value); 394 return d; 395 } 396 397 /** The civil ({@code yyyy-MM-dd}) portion of an ISO date/date-time, or {@code null} when it is not parseable. */ 398 private static LocalDate civilOrNull(String value) { 399 if (value == null || value.length() < 10) 400 return null; 401 if (value.length() > 10 && value.charAt(10) != 'T') 402 return null; 403 try { 404 return LocalDate.parse(value.substring(0, 10), DateTimeFormatter.ISO_LOCAL_DATE); 405 } catch (DateTimeParseException e) { 406 return null; 407 } 408 } 409 410 /** 411 * The local time of an ISO date-time, ignoring any trailing offset/{@code Z}, or {@code null} when the value is 412 * date-only or its time portion is not parseable (fail-soft: an odd time never rejects a shipped event). 413 */ 414 private static LocalTime civilTime(String value) { 415 if (value == null || value.length() <= 11 || value.charAt(10) != 'T') 416 return null; 417 var body = value.substring(11); 418 for (var i = 0; i < body.length(); i++) { 419 var c = body.charAt(i); 420 if (c == 'Z' || c == '+' || (c == '-' && i > 0)) { 421 body = body.substring(0, i); 422 break; 423 } 424 } 425 try { 426 return LocalTime.parse(body, DateTimeFormatter.ISO_LOCAL_TIME); 427 } catch (DateTimeParseException e) { 428 return null; 429 } 430 } 431 432 /** {@link #civilTime(String)} with an unparseable/absent time degraded to midnight. */ 433 private static LocalTime civilTimeOrMidnight(String value) { 434 var t = civilTime(value); 435 return t == null ? LocalTime.MIDNIGHT : t; 436 } 437}