001package edu.pdx.cs.joy.grader.canvas;
002
003import com.google.common.annotations.VisibleForTesting;
004import jakarta.json.Json;
005import jakarta.json.JsonArray;
006import jakarta.json.JsonNumber;
007import jakarta.json.JsonObject;
008import jakarta.json.JsonReader;
009import jakarta.json.JsonString;
010import jakarta.json.JsonValue;
011
012import java.io.IOException;
013import java.io.PrintStream;
014import java.io.StringReader;
015import java.net.URI;
016import java.net.http.HttpClient;
017import java.net.http.HttpHeaders;
018import java.net.http.HttpRequest;
019import java.net.http.HttpResponse;
020import java.nio.file.Files;
021import java.nio.file.Path;
022import java.time.Clock;
023import java.time.Instant;
024import java.time.LocalDate;
025import java.time.OffsetDateTime;
026import java.time.ZoneId;
027import java.time.format.DateTimeFormatterBuilder;
028import java.time.temporal.ChronoField;
029import java.util.ArrayList;
030import java.util.LinkedHashMap;
031import java.util.List;
032import java.util.Locale;
033import java.util.Map;
034import java.util.Optional;
035import java.util.TreeSet;
036import java.util.regex.Matcher;
037import java.util.regex.Pattern;
038import java.time.format.DateTimeFormatter;
039
040public class CompareCanvasAndWebsiteSchedules {
041  static final URI DEFAULT_CANVAS_BASE_URI = URI.create("https://canvas.pdx.edu");
042  private static final Pattern NEXT_LINK_PATTERN = Pattern.compile("<([^>]+)>;\\s*rel=\"next\"");
043  private static final DateTimeFormatter WEBSITE_DATE_FORMAT =
044    DateTimeFormatter.ofPattern("MMMM d, uuuu", Locale.US);
045  private static final DateTimeFormatter COMMENT_DATE_WITH_OPTIONAL_YEAR_FORMAT =
046    new DateTimeFormatterBuilder()
047      .parseCaseInsensitive()
048      .appendPattern("MMMM d")
049      .optionalStart()
050      .appendLiteral(", ")
051      .appendValue(ChronoField.YEAR, 4)
052      .optionalEnd()
053      .toFormatter(Locale.US);
054  private static final ZoneId PACIFIC_TIME_ZONE = ZoneId.of("America/Los_Angeles");
055  private static final String FINAL_EXAM_DURING_CLASS_TIME = "final exam during class time";
056  private static final Pattern PROJECT_NAME_PATTERN = Pattern.compile("^Project \\d+$");
057  private static final Pattern QUIZ_PREFIX_PATTERN = Pattern.compile("^quiz\\s+(\\d+)\\b.*");
058  private static final Pattern REFLECTION_PATTERN = Pattern.compile("^reflections on\\s+(.+)$");
059  private static final Pattern FINAL_EXAM_DATE_PATTERN = Pattern.compile(
060    "(?i)individual[^.]*final exam[^.]*?([A-Z][a-z]+\\s+\\d{1,2}(?:,\\s*\\d{4})?)|" +
061      "final exam[^.]*individual[^.]*?([A-Z][a-z]+\\s+\\d{1,2}(?:,\\s*\\d{4})?)");
062
063  private final HttpClient httpClient;
064  private final URI canvasBaseUri;
065  private final Clock clock;
066
067  public CompareCanvasAndWebsiteSchedules() {
068    this(HttpClient.newHttpClient(), DEFAULT_CANVAS_BASE_URI, Clock.systemUTC());
069  }
070
071  @VisibleForTesting
072  CompareCanvasAndWebsiteSchedules(HttpClient httpClient, URI canvasBaseUri) {
073    this(httpClient, canvasBaseUri, Clock.systemUTC());
074  }
075
076  @VisibleForTesting
077  CompareCanvasAndWebsiteSchedules(HttpClient httpClient, URI canvasBaseUri, Clock clock) {
078    this.httpClient = httpClient;
079    this.canvasBaseUri = canvasBaseUri;
080    this.clock = clock;
081  }
082
083  public static void main(String[] args) throws IOException, InterruptedException {
084    try {
085      new CompareCanvasAndWebsiteSchedules().run(args, System.out);
086
087    } catch (IllegalArgumentException ex) {
088      usage(ex.getMessage());
089
090    } catch (IllegalStateException ex) {
091      error(ex.getMessage());
092    }
093  }
094
095  @VisibleForTesting
096  void run(String[] args, PrintStream out) throws IOException, InterruptedException {
097    String apiTokenFileName = parseApiTokenFileName(args);
098    String websiteJsonFileName = parseWebsiteJsonFileName(args);
099    String apiToken = readApiToken(Path.of(apiTokenFileName));
100    CanvasCourse course = getNextCourse(apiToken);
101    List<CanvasAssignment> canvasAssignments = getAssignments(apiToken, course);
102
103    String websiteJson = readWebsiteJson(Path.of(websiteJsonFileName));
104    List<WebsiteAssignment> websiteAssignments = parseWebsiteAssignments(websiteJson);
105
106    ComparisonReport report = compareAssignments(canvasAssignments, websiteAssignments);
107    printSection(out, "Assignments with differing due dates:", report.differingDueDates());
108    out.println();
109    printSection(out, "Assignments whose due dates couldn't be determined:", report.undeterminedDueDates());
110    out.println();
111    printSection(out, "Assignments with matching due dates:", report.matchingDueDates());
112  }
113
114  private static String parseApiTokenFileName(String[] args) {
115    if (args.length == 0) {
116      throw new IllegalArgumentException("Missing API token file name");
117    }
118
119    return args[0];
120  }
121
122  private static String parseWebsiteJsonFileName(String[] args) {
123    if (args.length < 2) {
124      throw new IllegalArgumentException("Missing website JSON file name");
125    }
126
127    if (args.length > 2) {
128      throw new IllegalArgumentException("Extraneous command line argument: " + args[2]);
129    }
130
131    return args[1];
132  }
133
134  private static String readWebsiteJson(Path websiteJsonFile) throws IOException {
135    if (!Files.exists(websiteJsonFile)) {
136      throw new IllegalArgumentException("Website JSON file \"" + websiteJsonFile + "\" does not exist");
137    }
138
139    return Files.readString(websiteJsonFile);
140  }
141
142  @VisibleForTesting
143  static List<WebsiteAssignment> parseWebsiteAssignments(String json) {
144    List<WebsiteAssignment> assignments = new ArrayList<>();
145    try (JsonReader reader = Json.createReader(new StringReader(json))) {
146      JsonObject schedule = reader.readObject();
147      JsonArray meetings = schedule.getJsonArray("meetings");
148      JsonArray lectures = schedule.getJsonArray("lectures");
149
150      if (meetings == null) {
151        throw new IllegalArgumentException("Website schedule is missing meetings");
152      }
153
154      if (lectures == null) {
155        throw new IllegalArgumentException("Website schedule is missing lectures");
156      }
157
158      if (lectures.size() > meetings.size()) {
159        throw new IllegalArgumentException("Website schedule has more lectures than meetings");
160      }
161
162      for (int i = 0; i < lectures.size(); i++) {
163        JsonValue lectureValue = lectures.get(i);
164        if (lectureValue.getValueType() != JsonValue.ValueType.OBJECT) {
165          continue;
166        }
167
168        JsonObject lecture = lectureValue.asJsonObject();
169        JsonObject topics = lecture.getJsonObject("topics");
170        if (topics == null) {
171          continue;
172        }
173
174        LocalDate dueDate = parseWebsiteDate(meetings.getString(i));
175        addDueAssignments(assignments, topics, dueDate);
176      }
177
178      addFinalExamAssignments(assignments, lectures, meetings, parseWebsiteDate(meetings.getString(meetings.size() - 1)).getYear());
179    }
180
181    addPlanOfAttackAssignments(assignments);
182    return assignments;
183  }
184
185  private static LocalDate parseWebsiteDate(String text) {
186    return LocalDate.parse(text, WEBSITE_DATE_FORMAT);
187  }
188
189  private static void addDueAssignments(List<WebsiteAssignment> assignments, JsonObject topics, LocalDate dueDate) {
190    JsonValue due = topics.get("due");
191    if (due instanceof JsonString) {
192      assignments.add(new WebsiteAssignment(((JsonString) due).getString(), dueDate));
193
194    } else if (due != null && due.getValueType() == JsonValue.ValueType.ARRAY) {
195      JsonArray array = topics.getJsonArray("due");
196      for (JsonValue value : array) {
197        if (value instanceof JsonString) {
198          assignments.add(new WebsiteAssignment(((JsonString) value).getString(), dueDate));
199        }
200      }
201    }
202
203    JsonObject quiz = topics.getJsonObject("quiz");
204    if (quiz != null && quiz.get("number") instanceof JsonNumber) {
205      assignments.add(new WebsiteAssignment("Quiz " + quiz.getInt("number"), dueDate));
206    }
207
208    JsonObject survey = topics.getJsonObject("survey");
209    if (survey != null && survey.get("name") instanceof JsonString) {
210      assignments.add(new WebsiteAssignment(survey.getString("name") + " Survey", dueDate));
211    }
212
213    JsonObject reflection = topics.getJsonObject("reflection");
214    if (reflection != null && reflection.get("title") instanceof JsonString) {
215      assignments.add(new WebsiteAssignment("Reflections on " + reflection.getString("title"), dueDate));
216    }
217  }
218
219  private static void addPlanOfAttackAssignments(List<WebsiteAssignment> assignments) {
220    List<WebsiteAssignment> poaAssignments = new ArrayList<>();
221    for (WebsiteAssignment assignment : assignments) {
222      if (PROJECT_NAME_PATTERN.matcher(assignment.name()).matches()) {
223        poaAssignments.add(new WebsiteAssignment(assignment.name() + " POA", assignment.dueDate().minusDays(3)));
224      }
225    }
226
227    assignments.addAll(poaAssignments);
228  }
229
230  private static void addFinalExamAssignments(List<WebsiteAssignment> assignments, JsonArray lectures, JsonArray meetings, int defaultYear) {
231    LocalDate individualDueDate = findFinalExamIndividualDueDate(lectures, meetings, defaultYear);
232    if (individualDueDate == null) {
233      return;
234    }
235
236    assignments.add(new WebsiteAssignment("Final Exam (Individual)", individualDueDate));
237    assignments.add(new WebsiteAssignment("Final Exam (Group)", individualDueDate.plusDays(1)));
238  }
239
240  private static LocalDate findFinalExamIndividualDueDate(JsonArray lectures, JsonArray meetings, int defaultYear) {
241    for (int i = 0; i < lectures.size(); i++) {
242      JsonValue lectureValue = lectures.get(i);
243      if (lectureValue.getValueType() != JsonValue.ValueType.OBJECT) {
244        continue;
245      }
246
247      JsonObject lecture = lectureValue.asJsonObject();
248      JsonValue comment = lecture.get("comment");
249      if (comment instanceof JsonString) {
250        String commentText = ((JsonString) comment).getString();
251        LocalDate maybeDate = parseFinalExamDateFromComment(commentText, defaultYear);
252        if (maybeDate == null && commentText.toLowerCase(Locale.US).contains(FINAL_EXAM_DURING_CLASS_TIME)) {
253          maybeDate = parseWebsiteDate(meetings.getString(i));
254        }
255        if (maybeDate != null) {
256          return maybeDate;
257        }
258      }
259    }
260
261    return null;
262  }
263
264  private static LocalDate parseFinalExamDateFromComment(String comment, int defaultYear) {
265    Matcher matcher = FINAL_EXAM_DATE_PATTERN.matcher(comment);
266    if (!matcher.find()) {
267      return null;
268    }
269
270    String dateText = matcher.group(1) != null ? matcher.group(1) : matcher.group(2);
271    if (dateText == null) {
272      return null;
273    }
274
275    LocalDate parsed = LocalDate.parse(dateText.trim(), COMMENT_DATE_WITH_OPTIONAL_YEAR_FORMAT);
276    if (!dateText.contains(",")) {
277      parsed = parsed.withYear(defaultYear);
278    }
279
280    return parsed;
281  }
282
283  private static String readApiToken(Path apiTokenFile) throws IOException {
284    if (!Files.exists(apiTokenFile)) {
285      throw new IllegalArgumentException("API token file \"" + apiTokenFile + "\" does not exist");
286    }
287
288    String apiToken = Files.readString(apiTokenFile).trim();
289    if (apiToken.isEmpty()) {
290      throw new IllegalArgumentException("API token file \"" + apiTokenFile + "\" is empty");
291    }
292
293    return apiToken;
294  }
295
296  @VisibleForTesting
297  CanvasCourse getNextCourse(String apiToken) throws IOException, InterruptedException {
298    List<CanvasCourse> courses = new ArrayList<>();
299    URI nextPage = getCoursesUri();
300
301    while (nextPage != null) {
302      HttpResponse<String> response = invokeCanvas(nextPage, apiToken);
303      courses.addAll(parseCourses(response.body()));
304      nextPage = getNextPage(response.headers());
305    }
306
307    Instant now = this.clock.instant();
308    Optional<CanvasCourse> nextCourse = courses.stream()
309      .filter(course -> course.startAt().isAfter(now))
310      .min((left, right) -> left.startAt().compareTo(right.startAt()));
311
312    return nextCourse
313      .orElseThrow(() -> new IllegalStateException("No upcoming Canvas course offerings found"));
314  }
315
316  @VisibleForTesting
317  List<CanvasAssignment> getAssignments(String apiToken, CanvasCourse course) throws IOException, InterruptedException {
318    List<CanvasAssignment> assignments = new ArrayList<>();
319    URI nextPage = getAssignmentsUri(course);
320
321    while (nextPage != null) {
322      HttpResponse<String> response = invokeCanvas(nextPage, apiToken);
323      assignments.addAll(parseAssignments(response.body()));
324      nextPage = getNextPage(response.headers());
325    }
326
327    return assignments;
328  }
329
330  private URI getCoursesUri() {
331    return this.canvasBaseUri.resolve("/api/v1/courses?per_page=100");
332  }
333
334  private URI getAssignmentsUri(CanvasCourse course) {
335    return this.canvasBaseUri.resolve("/api/v1/courses/" + course.id() + "/assignments?per_page=100");
336  }
337
338  private HttpResponse<String> invokeCanvas(URI uri, String apiToken) throws IOException, InterruptedException {
339    HttpRequest request = HttpRequest.newBuilder(uri)
340      .header("Authorization", "Bearer " + apiToken)
341      .header("Accept", "application/json")
342      .GET()
343      .build();
344
345    HttpResponse<String> response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString());
346    if (response.statusCode() != 200) {
347      throw new IOException("Canvas request to " + uri + " failed with status code " + response.statusCode());
348    }
349
350    return response;
351  }
352
353  private URI getNextPage(HttpHeaders headers) {
354    for (String linkHeader : headers.allValues("Link")) {
355      Matcher matcher = NEXT_LINK_PATTERN.matcher(linkHeader);
356      if (matcher.find()) {
357        return URI.create(matcher.group(1));
358      }
359    }
360
361    return null;
362  }
363
364  @VisibleForTesting
365  static List<CanvasCourse> parseCourses(String json) {
366    List<CanvasCourse> courses = new ArrayList<>();
367    try (JsonReader reader = Json.createReader(new StringReader(json))) {
368      JsonArray jsonCourses = reader.readArray();
369      for (JsonValue courseValue : jsonCourses) {
370        if (courseValue.getValueType() != JsonValue.ValueType.OBJECT) {
371          continue;
372        }
373
374        JsonObject course = courseValue.asJsonObject();
375        JsonValue id = course.get("id");
376        JsonValue name = course.get("name");
377        JsonValue startAt = course.get("start_at");
378        if (id instanceof JsonNumber && name instanceof JsonString && startAt instanceof JsonString) {
379          courses.add(new CanvasCourse(
380            ((JsonNumber) id).intValue(),
381            ((JsonString) name).getString(),
382            OffsetDateTime.parse(((JsonString) startAt).getString()).toInstant()));
383        }
384      }
385
386      return courses;
387    }
388  }
389
390  @VisibleForTesting
391  static List<CanvasAssignment> parseAssignments(String json) {
392    List<CanvasAssignment> assignments = new ArrayList<>();
393    try (JsonReader reader = Json.createReader(new StringReader(json))) {
394      JsonArray jsonAssignments = reader.readArray();
395      for (JsonValue assignmentValue : jsonAssignments) {
396        if (assignmentValue.getValueType() != JsonValue.ValueType.OBJECT) {
397          continue;
398        }
399
400        JsonObject assignment = assignmentValue.asJsonObject();
401        JsonValue name = assignment.get("name");
402        if (!(name instanceof JsonString)) {
403          continue;
404        }
405
406        JsonValue dueAt = assignment.get("due_at");
407        Optional<LocalDate> dueDate = Optional.empty();
408        if (dueAt instanceof JsonString) {
409          dueDate = Optional.of(OffsetDateTime.parse(((JsonString) dueAt).getString())
410            .atZoneSameInstant(PACIFIC_TIME_ZONE)
411            .toLocalDate());
412        }
413
414        assignments.add(new CanvasAssignment(((JsonString) name).getString(), dueDate));
415      }
416
417      return assignments;
418    }
419  }
420
421  @VisibleForTesting
422  static ComparisonReport compareAssignments(List<CanvasAssignment> canvasAssignments, List<WebsiteAssignment> websiteAssignments) {
423    List<ComparisonRow> differing = new ArrayList<>();
424    List<ComparisonRow> undetermined = new ArrayList<>();
425    List<ComparisonRow> matching = new ArrayList<>();
426
427    List<WebsiteAssignment> unmatchedWebsiteAssignments = new ArrayList<>(websiteAssignments);
428    for (CanvasAssignment canvasAssignment : canvasAssignments) {
429      WebsiteAssignment websiteAssignment = findAndRemoveMatchingWebsiteAssignment(canvasAssignment, unmatchedWebsiteAssignments);
430
431      if (websiteAssignment == null) {
432        undetermined.add(new ComparisonRow(
433          canvasAssignment.name(),
434          formatCanvasDate(canvasAssignment.dueDate()),
435          formatWebsiteDate(null)));
436
437      } else {
438        ComparisonRow row = new ComparisonRow(
439          websiteAssignment.name(),
440          formatCanvasDate(canvasAssignment.dueDate()),
441          formatWebsiteDate(websiteAssignment.dueDate()));
442
443        if (canvasAssignment.dueDate().isEmpty()) {
444          undetermined.add(row);
445
446        } else if (canvasAssignment.dueDate().get().equals(websiteAssignment.dueDate())) {
447          matching.add(row);
448
449        } else {
450          differing.add(row);
451        }
452      }
453    }
454
455    for (WebsiteAssignment websiteAssignment : unmatchedWebsiteAssignments) {
456      undetermined.add(new ComparisonRow(
457        websiteAssignment.name(),
458        formatCanvasDate(null),
459        formatWebsiteDate(websiteAssignment.dueDate())));
460    }
461
462    sortRowsByWebsiteDueDate(differing);
463    sortRowsByWebsiteDueDate(undetermined);
464    sortRowsByWebsiteDueDate(matching);
465
466    return new ComparisonReport(differing, undetermined, matching);
467  }
468
469  private static WebsiteAssignment findAndRemoveMatchingWebsiteAssignment(CanvasAssignment canvasAssignment,
470                                                                          List<WebsiteAssignment> websiteAssignments) {
471    WebsiteAssignment match = findMatchingWebsiteAssignment(canvasAssignment, websiteAssignments,
472      (canvas, website) -> canvas.equals(website));
473    if (match == null) {
474      match = findMatchingWebsiteAssignment(canvasAssignment, websiteAssignments,
475        (canvas, website) -> canvas.equalsIgnoreCase(website));
476    }
477    if (match == null) {
478      String normalizedCanvasName = normalizeAssignmentName(canvasAssignment.name());
479      match = findMatchingWebsiteAssignment(canvasAssignment, websiteAssignments,
480        (canvas, website) -> normalizedCanvasName.equals(normalizeAssignmentName(website)));
481    }
482
483    if (match != null) {
484      websiteAssignments.remove(match);
485    }
486    return match;
487  }
488
489  private static WebsiteAssignment findMatchingWebsiteAssignment(CanvasAssignment canvasAssignment,
490                                                                 List<WebsiteAssignment> websiteAssignments,
491                                                                 AssignmentNameMatcher matcher) {
492    for (WebsiteAssignment websiteAssignment : websiteAssignments) {
493      if (matcher.matches(canvasAssignment.name(), websiteAssignment.name())) {
494        return websiteAssignment;
495      }
496    }
497
498    return null;
499  }
500
501  private static String normalizeAssignmentName(String name) {
502    String normalized = name.toLowerCase(Locale.US).trim();
503    normalized = normalized.replace(':', ' ');
504    normalized = normalized.replaceAll("\\s+", " ");
505    normalized = normalizeQuizName(normalized);
506    normalized = normalizeReflectionName(normalized);
507    return normalized.trim();
508  }
509
510  private static String normalizeQuizName(String normalized) {
511    Matcher matcher = QUIZ_PREFIX_PATTERN.matcher(normalized);
512    if (matcher.matches()) {
513      return "quiz " + matcher.group(1);
514    }
515
516    return normalized;
517  }
518
519  private static String normalizeReflectionName(String normalized) {
520    Matcher matcher = REFLECTION_PATTERN.matcher(normalized);
521    if (!matcher.matches()) {
522      return normalized;
523    }
524
525    String topic = matcher.group(1)
526      .replace("your experiences ", "")
527      .trim();
528    return "reflections on " + topic;
529  }
530
531  private static void sortRowsByWebsiteDueDate(List<ComparisonRow> rows) {
532    rows.sort((left, right) -> {
533      int byDate = websiteSortKey(left).compareTo(websiteSortKey(right));
534      if (byDate != 0) {
535        return byDate;
536      }
537
538      return left.assignmentName().compareToIgnoreCase(right.assignmentName());
539    });
540  }
541
542  private static LocalDate websiteSortKey(ComparisonRow row) {
543    if ("(not found)".equals(row.websiteDueDate())) {
544      return LocalDate.MAX;
545    }
546
547    return LocalDate.parse(row.websiteDueDate());
548  }
549
550  private static String formatCanvasDate(Optional<LocalDate> dueDate) {
551    if (dueDate == null) {
552      return "(not found)";
553    }
554
555    return dueDate.map(LocalDate::toString).orElse("(no due date)");
556  }
557
558  private static String formatWebsiteDate(LocalDate dueDate) {
559    if (dueDate == null) {
560      return "(not found)";
561    }
562
563    return dueDate.toString();
564  }
565
566  private static void printSection(PrintStream out, String heading, List<ComparisonRow> rows) {
567    out.println(heading);
568    if (rows.isEmpty()) {
569      out.println("(none)");
570
571    } else {
572      int assignmentWidth = "Assignment".length();
573      int canvasWidth = "Canvas".length();
574      int websiteWidth = "Website".length();
575
576      for (ComparisonRow row : rows) {
577        assignmentWidth = Math.max(assignmentWidth, row.assignmentName().length());
578        canvasWidth = Math.max(canvasWidth, row.canvasDueDate().length());
579        websiteWidth = Math.max(websiteWidth, row.websiteDueDate().length());
580      }
581
582      out.println(formatTableRow("Assignment", "Canvas", "Website", assignmentWidth, canvasWidth, websiteWidth));
583      out.println(formatTableRow("-".repeat(assignmentWidth), "-".repeat(canvasWidth), "-".repeat(websiteWidth),
584        assignmentWidth, canvasWidth, websiteWidth));
585      for (ComparisonRow row : rows) {
586        out.println(formatTableRow(row.assignmentName(), row.canvasDueDate(), row.websiteDueDate(),
587          assignmentWidth, canvasWidth, websiteWidth));
588      }
589    }
590  }
591
592  private static String formatTableRow(String assignment, String canvas, String website, int assignmentWidth, int canvasWidth,
593                                       int websiteWidth) {
594    return padRight(assignment, assignmentWidth) + "  " +
595      padRight(canvas, canvasWidth) + "  " +
596      website;
597  }
598
599  private static String padRight(String value, int width) {
600    return value + " ".repeat(width - value.length());
601  }
602
603  private static void usage(String message) {
604    PrintStream err = System.err;
605
606    err.println("+++ " + message);
607    err.println();
608    err.println("usage: java CompareCanvasAndWebsiteSchedules apiTokenFileName websiteJsonFileName");
609    err.println("    apiTokenFileName             File containing the Canvas API token");
610    err.println("    websiteJsonFileName          File containing the website schedule JSON");
611    err.println();
612    err.println("Compares assignment due dates from Canvas and the website schedule JSON");
613    err.println();
614
615    System.exit(1);
616  }
617
618  private static void error(String message) {
619    System.err.println("+++ " + message);
620    System.exit(1);
621  }
622
623  @VisibleForTesting
624  static record CanvasCourse(int id, String name, Instant startAt) {
625  }
626
627  @VisibleForTesting
628  static record CanvasAssignment(String name, Optional<LocalDate> dueDate) {
629    String dueDateAsText() {
630      return this.dueDate.map(LocalDate::toString).orElse("(no due date)");
631    }
632  }
633
634  @VisibleForTesting
635  static record WebsiteAssignment(String name, LocalDate dueDate) {
636    String dueDateAsText() {
637      return this.dueDate.toString();
638    }
639  }
640
641  @VisibleForTesting
642  static record ComparisonReport(List<ComparisonRow> differingDueDates, List<ComparisonRow> undeterminedDueDates, List<ComparisonRow> matchingDueDates) {
643  }
644
645  @VisibleForTesting
646  static record ComparisonRow(String assignmentName, String canvasDueDate, String websiteDueDate) {
647  }
648
649  @FunctionalInterface
650  private interface AssignmentNameMatcher {
651    boolean matches(String canvasName, String websiteName);
652  }
653}