moving v2 to top level

This commit is contained in:
2024-12-17 09:19:21 -07:00
parent 5f0b3554dc
commit 576ee02afb
468 changed files with 79 additions and 15430 deletions

View File

@@ -0,0 +1,82 @@
import { CanvasDiscussionTopicModel } from "../discussions/canvasDiscussionModelTopic";
import { CanvasSubmissionModel } from "../submissions/canvasSubmissionModel";
import { CanvasAssignmentDate } from "./canvasAssignmentDate";
import { CanvasAssignmentOverride } from "./canvasAssignmentOverride";
import { CanvasExternalToolTagAttributes } from "./canvasExternalToolTagAttributes";
import { CanvasLockInfo } from "./canvasLockInfo";
import { CanvasRubricCriteria } from "./canvasRubricCriteria";
import { CanvasTurnitinSettings } from "./canvasTurnitinSettings";
export interface CanvasAssignment {
id: number;
name: string;
description: string;
created_at: string; // ISO 8601 date string
has_overrides: boolean;
course_id: number;
html_url: string;
submissions_download_url: string;
assignment_group_id: number;
due_date_required: boolean;
max_name_length: number;
peer_reviews: boolean;
automatic_peer_reviews: boolean;
position: number;
grading_type: string;
published: boolean;
unpublishable: boolean;
only_visible_to_overrides: boolean;
locked_for_user: boolean;
moderated_grading: boolean;
grader_count: number;
allowed_attempts: number;
is_quiz_assignment: boolean;
submission_types: string[];
updated_at?: string; // ISO 8601 date string
due_at?: string; // ISO 8601 date string
lock_at?: string; // ISO 8601 date string
unlock_at?: string; // ISO 8601 date string
all_dates?: CanvasAssignmentDate[];
allowed_extensions?: string[];
turnitin_enabled?: boolean;
vericite_enabled?: boolean;
turnitin_settings?: CanvasTurnitinSettings;
grade_group_students_individually?: boolean;
external_tool_tag_attributes?: CanvasExternalToolTagAttributes;
peer_review_count?: number;
peer_reviews_assign_at?: string; // ISO 8601 date string
intra_group_peer_reviews?: boolean;
group_category_id?: number;
needs_grading_count?: number;
needs_grading_count_by_section?: {
section_id: string;
needs_grading_count: number;
}[];
post_to_sis?: boolean;
integration_id?: string;
integration_data?: any;
muted?: boolean;
points_possible?: number;
has_submitted_submissions?: boolean;
grading_standard_id?: number;
lock_info?: CanvasLockInfo;
lock_explanation?: string;
quiz_id?: number;
anonymous_submissions?: boolean;
discussion_topic?: CanvasDiscussionTopicModel;
freeze_on_copy?: boolean;
frozen?: boolean;
frozen_attributes?: string[];
submission?: CanvasSubmissionModel;
use_rubric_for_grading?: boolean;
rubric_settings?: any;
rubric?: CanvasRubricCriteria[];
assignment_visibility?: number[];
overrides?: CanvasAssignmentOverride[];
omit_from_final_grade?: boolean;
final_grader_id?: number;
grader_comments_visible_to_graders?: boolean;
graders_anonymous_to_graders?: boolean;
grader_names_visible_to_final_grader?: boolean;
anonymous_grading?: boolean;
}

View File

@@ -0,0 +1,8 @@
export interface CanvasAssignmentDate {
title: string;
id?: number;
base?: boolean;
due_at?: string; // ISO 8601 date string
unlock_at?: string; // ISO 8601 date string
lock_at?: string; // ISO 8601 date string
}

View File

@@ -0,0 +1,10 @@
export interface CanvasAssignmentGroup {
id: number; // TypeScript doesn't have `ulong`, so using `number` for large integers.
name: string;
position: number;
group_weight: number;
// sis_source_id?: string; // Uncomment if needed.
// integration_data?: Record<string, string>; // Uncomment if needed.
// assignments?: CanvasAssignment[]; // Uncomment if needed, assuming CanvasAssignment is defined.
// rules?: any; // Assuming 'rules' is of unknown type, so using 'any' here.
}

View File

@@ -0,0 +1,13 @@
export interface CanvasAssignmentOverride {
id: number;
assignment_id: number;
course_section_id: number;
title: string;
student_ids?: number[];
group_id?: number;
due_at?: string; // ISO 8601 date string
all_day?: boolean;
all_day_date?: string; // ISO 8601 date string
unlock_at?: string; // ISO 8601 date string
lock_at?: string; // ISO 8601 date string
}

View File

@@ -0,0 +1,5 @@
export interface CanvasExternalToolTagAttributes {
url: string;
resource_link_id: string;
new_tab?: boolean;
}

View File

@@ -0,0 +1,7 @@
export interface CanvasLockInfo {
asset_string: string;
unlock_at?: string; // ISO 8601 date string
lock_at?: string; // ISO 8601 date string
context_module?: any;
manually_locked?: boolean;
}

View File

@@ -0,0 +1,13 @@
export interface CanvasRubric {
id?: number;
title: string;
context_id: number;
context_type: string;
points_possible: number;
reusable: boolean;
read_only: boolean;
hide_score_total?: boolean;
// Uncomment and define if needed
// data: CanvasRubricCriteria[];
// free_form_criterion_comments?: boolean;
}

View File

@@ -0,0 +1,12 @@
export interface CanvasRubricAssociation {
id: number;
rubric_id: number;
association_id: number;
association_type: string;
use_for_grading: boolean;
summary_data?: string;
purpose: string;
hide_score_total?: boolean;
hide_points: boolean;
hide_outcome_results: boolean;
}

View File

@@ -0,0 +1,7 @@
import { CanvasRubric } from "./canvasRubric";
import { CanvasRubricAssociation } from "./canvasRubricAssociation";
export interface CanvasRubricCreationResponse {
rubric: CanvasRubric;
rubric_association: CanvasRubricAssociation;
}

View File

@@ -0,0 +1,16 @@
export interface CanvasRubricCriteria {
id: string;
description: string;
long_description: string;
points?: number;
learning_outcome_id?: string;
vendor_guid?: string;
criterion_use_range?: boolean;
ratings?: {
points: number;
id: string;
description: string;
long_description: string;
}[];
ignore_for_scoring?: boolean;
}

View File

@@ -0,0 +1,10 @@
export interface CanvasTurnitinSettings {
originality_report_visibility: string;
s_paper_check: boolean;
internet_check: boolean;
journal_check: boolean;
exclude_biblio: boolean;
exclude_quoted: boolean;
exclude_small_matches_type?: boolean;
exclude_small_matches_value?: number;
}

View File

@@ -0,0 +1,3 @@
export interface CanvasCalendarLinkModel {
ics: string;
}

View File

@@ -0,0 +1,56 @@
import { CanvasEnrollmentModel } from "../enrollments/canvasEnrollmentModel";
import { CanvasCalendarLinkModel } from "./canvasCalendarLinkModel";
import { CanvasCourseProgressModel } from "./canvasCourseProgressModel";
import { CanvasTermModel } from "./canvasTermModel";
export interface CanvasCourseModel {
id: number;
sis_course_id: string;
uuid: string;
integration_id: string;
name: string;
course_code: string;
workflow_state: string;
account_id: number;
root_account_id: number;
enrollment_term_id: number;
created_at: string; // ISO 8601 date string
locale: string;
calendar: CanvasCalendarLinkModel;
default_view: string;
syllabus_body: string;
permissions: { [key: string]: boolean };
storage_quota_mb: number;
storage_quota_used_mb: number;
license: string;
course_format: string;
time_zone: string;
sis_import_id?: number;
grading_standard_id?: number;
start_at?: string; // ISO 8601 date string
end_at?: string; // ISO 8601 date string
enrollments?: CanvasEnrollmentModel[];
total_students?: number;
needs_grading_count?: number;
term?: CanvasTermModel;
course_progress?: CanvasCourseProgressModel;
apply_assignment_group_weights?: boolean;
is_public?: boolean;
is_public_to_auth_users?: boolean;
public_syllabus?: boolean;
public_syllabus_to_auth?: boolean;
public_description?: string;
hide_final_grades?: boolean;
allow_student_assignment_edits?: boolean;
allow_wiki_comments?: boolean;
allow_student_forum_attachments?: boolean;
open_enrollment?: boolean;
self_enrollment?: boolean;
restrict_enrollments_to_course_dates?: boolean;
access_restricted_by_date?: boolean;
blueprint?: boolean;
blueprint_restrictions?: { [key: string]: boolean };
blueprint_restrictions_by_object_type?: {
[key: string]: { [key: string]: boolean };
};
}

View File

@@ -0,0 +1,6 @@
export interface CanvasCourseProgressModel {
requirement_count?: number;
requirement_completed_count?: number;
next_requirement_url?: string;
completed_at?: string; // ISO 8601 date string
}

View File

@@ -0,0 +1,16 @@
export interface CanvasCourseSettingsModel {
allow_final_grade_override: boolean;
allow_student_discussion_topics: boolean;
allow_student_forum_attachments: boolean;
allow_student_discussion_editing: boolean;
grading_standard_enabled: boolean;
allow_student_organized_groups: boolean;
hide_final_grades: boolean;
hide_distribution_graphs: boolean;
lock_all_announcements: boolean;
restrict_student_past_view: boolean;
restrict_student_future_view: boolean;
show_announcements_on_home_page: boolean;
home_page_announcement_limit: number;
grading_standard_id?: number;
}

View File

@@ -0,0 +1,6 @@
export interface CanvasTermModel {
id: number;
name: string;
start_at?: string; // ISO 8601 date string
end_at?: string; // ISO 8601 date string
}

View File

@@ -0,0 +1,40 @@
import { CanvasUserDisplayModel } from "../users/userDisplayModel";
import { CanvasFileAttachmentModel } from "./canvasFileAttachmentModel";
export interface CanvasDiscussionTopicModel {
id: number;
title: string;
message: string;
html_url: string;
read_state: string;
subscription_hold: string;
assignment_id: number;
lock_explanation: string;
user_name: string;
topic_children: number[];
podcast_url: string;
discussion_type: string;
attachments: CanvasFileAttachmentModel[];
permissions: { [key: string]: boolean };
author: CanvasUserDisplayModel;
unread_count?: number;
subscribed?: boolean;
posted_at?: string; // ISO 8601 date string
last_reply_at?: string; // ISO 8601 date string
require_initial_post?: boolean;
user_can_see_posts?: boolean;
discussion_subentry_count?: number;
delayed_post_at?: string; // ISO 8601 date string
published?: boolean;
lock_at?: string; // ISO 8601 date string
locked?: boolean;
pinned?: boolean;
locked_for_user?: boolean;
lock_info?: any;
group_topic_children?: any;
root_topic_id?: number;
group_category_id?: number;
allow_rating?: boolean;
only_graders_can_rate?: boolean;
sort_by_rating?: boolean;
}

View File

@@ -0,0 +1,6 @@
export interface CanvasFileAttachmentModel {
content_type: string;
url: string;
filename: string;
display_name: string;
}

View File

@@ -0,0 +1,16 @@
export interface CanvasEnrollmentTermModel {
id: number;
name: string;
sis_term_id?: string;
sis_import_id?: number;
start_at?: string; // ISO 8601 date string
end_at?: string; // ISO 8601 date string
grading_period_group_id?: number;
workflow_state?: string;
overrides?: {
[key: string]: {
start_at?: string; // ISO 8601 date string
end_at?: string; // ISO 8601 date string
};
};
}

View File

@@ -0,0 +1,48 @@
import { CanvasUserDisplayModel } from "../users/userDisplayModel";
import { CanvasGradeModel } from "./canvasGradeModel";
export interface CanvasEnrollmentModel {
id: number;
course_id: number;
enrollment_state: string;
type: string;
user_id: number;
role: string;
role_id: number;
html_url: string;
grades: CanvasGradeModel;
user: CanvasUserDisplayModel;
override_grade: string;
sis_course_id?: string;
course_integration_id?: string;
course_section_id?: number;
section_integration_id?: string;
sis_account_id?: string;
sis_section_id?: string;
sis_user_id?: string;
limit_privileges_to_course_section?: boolean;
sis_import_id?: number;
root_account_id?: number;
associated_user_id?: number;
created_at?: string; // ISO 8601 date string
updated_at?: string; // ISO 8601 date string
start_at?: string; // ISO 8601 date string
end_at?: string; // ISO 8601 date string
last_activity_at?: string; // ISO 8601 date string
last_attended_at?: string; // ISO 8601 date string
total_activity_time?: number;
override_score?: number;
unposted_current_grade?: string;
unposted_final_grade?: string;
unposted_current_score?: string;
unposted_final_score?: string;
has_grading_periods?: boolean;
totals_for_all_grading_periods_option?: boolean;
current_grading_period_title?: string;
current_grading_period_id?: number;
current_period_override_grade?: string;
current_period_override_score?: number;
current_period_unposted_final_score?: number;
current_period_unposted_current_grade?: string;
current_period_unposted_final_grade?: string;
}

View File

@@ -0,0 +1,11 @@
export interface CanvasGradeModel {
html_url?: string;
current_grade?: number;
final_grade?: number;
current_score?: number;
final_score?: number;
unposted_current_grade?: number;
unposted_final_grade?: number;
unposted_current_score?: number;
unposted_final_score?: number;
}

View File

@@ -0,0 +1,18 @@
import { CanvasModuleItem } from "./canvasModuleItems";
export interface CanvasModule {
id: number;
workflow_state: string;
position: number;
name: string;
unlock_at?: string; // ISO 8601 date string
require_sequential_progress?: boolean;
prerequisite_module_ids?: number[];
items_count: number;
items_url: string;
items?: CanvasModuleItem[];
state?: string;
completed_at?: string; // ISO 8601 date string
publish_final_grade?: boolean;
published?: boolean;
}

View File

@@ -0,0 +1,26 @@
export interface CanvasModuleItem {
id: number;
module_id: number;
position: number;
title: string;
indent?: number;
type: string;
content_id?: number;
html_url: string;
url?: string;
page_url?: string;
external_url?: string;
new_tab: boolean;
completion_requirement?: {
type: string;
min_score?: number;
completed?: boolean;
};
published?: boolean;
content_details?: {
due_at?: string; // ISO 8601 date string
lock_at?: string; // ISO 8601 date string
points_possible: number;
locked_for_user: boolean;
};
}

View File

@@ -0,0 +1,16 @@
export interface CanvasPage {
page_id: number;
url: string;
title: string;
published: boolean;
front_page: boolean;
body?: string;
// Uncomment and define if needed
// created_at: string; // ISO 8601 date string
// updated_at: string; // ISO 8601 date string
// editing_roles: string;
// last_edited_by: UserDisplayModel;
// locked_for_user: boolean;
// lock_info?: LockInfoModel;
// lock_explanation?: string;
}

View File

@@ -0,0 +1,19 @@
export interface CanvasQuizAnswer {
id: number;
text: string;
html?: string;
weight: number;
// comments?: string;
// text_after_answers?: string;
// answer_match_left?: string;
// answer_match_right?: string;
// matching_answer_incorrect_matches?: string;
// numerical_answer_type?: string;
// exact?: number;
// margin?: number;
// approximate?: number;
// precision?: number;
// start?: number;
// end?: number;
// blank_id?: number;
}

View File

@@ -0,0 +1,44 @@
import { CanvasLockInfo } from "../assignments/canvasLockInfo";
import { CanvasQuizPermissions } from "./canvasQuizPermission";
export interface CanvasQuiz {
id: number;
title: string;
html_url: string;
mobile_url: string;
preview_url?: string;
description: string;
quiz_type: string;
assignment_group_id?: number;
time_limit?: number;
shuffle_answers?: boolean;
hide_results?: string;
show_correct_answers?: boolean;
show_correct_answers_last_attempt?: boolean;
show_correct_answers_at?: string; // ISO 8601 date string
hide_correct_answers_at?: string; // ISO 8601 date string
one_time_results?: boolean;
scoring_policy?: string;
allowed_attempts: number;
one_question_at_a_time?: boolean;
question_count?: number;
points_possible?: number;
cant_go_back?: boolean;
access_code?: string;
ip_filter?: string;
due_at?: string; // ISO 8601 date string
lock_at?: string; // ISO 8601 date string
unlock_at?: string; // ISO 8601 date string
published?: boolean;
unpublishable?: boolean;
locked_for_user?: boolean;
lock_info?: CanvasLockInfo;
lock_explanation?: string;
speedgrader_url?: string;
quiz_extensions_url?: string;
permissions: CanvasQuizPermissions;
all_dates?: any; // Depending on the structure of the dates, this could be further specified
version_number?: number;
question_types?: string[];
anonymous_submissions?: boolean;
}

View File

@@ -0,0 +1,9 @@
export interface CanvasQuizPermissions {
read: boolean;
submit: boolean;
create: boolean;
manage: boolean;
read_statistics: boolean;
review_grades: boolean;
update: boolean;
}

View File

@@ -0,0 +1,14 @@
import { CanvasQuizAnswer } from "./canvasQuizAnswerModel";
export interface CanvasQuizQuestion {
id: number;
quiz_id: number;
position?: number;
question_name: string;
question_type: string;
question_text: string;
correct_comments: string;
incorrect_comments: string;
neutral_comments: string;
answers?: CanvasQuizAnswer[];
}

View File

@@ -0,0 +1,50 @@
import { CanvasAssignment } from "../assignments/canvasAssignment";
import { CanvasCourseModel } from "../courses/canvasCourseModel";
import { CanvasUserModel } from "../users/canvasUserModel";
import { CanvasUserDisplayModel } from "../users/userDisplayModel";
export interface CanvasSubmissionModel {
assignment_id: number;
grade: string;
html_url: string;
preview_url: string;
submission_type: string;
user_id: number;
user: CanvasUserModel;
workflow_state: string;
late_policy_status: string;
assignment?: CanvasAssignment;
course?: CanvasCourseModel;
attempt?: number;
body?: string;
grade_matches_current_submission?: boolean;
score?: number;
submission_comments?: {
id: number;
author_id: number;
author_name: string;
author: CanvasUserDisplayModel;
comment: string;
created_at: string; // ISO 8601 date string
edited_at?: string; // ISO 8601 date string
media_comment?: {
content_type: string;
display_name: string;
media_id: string;
media_type: string;
url: string;
};
}[];
submitted_at?: string; // ISO 8601 date string
url?: string;
grader_id?: number;
graded_at?: string; // ISO 8601 date string
late?: boolean;
assignment_visible?: boolean;
excused?: boolean;
missing?: boolean;
points_deducted?: number;
seconds_late?: number;
extra_attempts?: number;
anonymous_id?: string;
}

View File

@@ -0,0 +1,21 @@
import { CanvasEnrollmentModel } from "../enrollments/canvasEnrollmentModel";
export interface CanvasUserModel {
id: number;
name: string;
sortable_name: string;
short_name: string;
sis_user_id: string;
integration_id: string;
login_id: string;
avatar_url: string;
enrollments: CanvasEnrollmentModel[];
email: string;
locale: string;
effective_locale: string;
time_zone: string;
bio: string;
permissions: { [key: string]: boolean };
sis_import_id?: number;
last_login?: string; // ISO 8601 date string
}

View File

@@ -0,0 +1,9 @@
export interface CanvasUserDisplayModel {
avatar_image_url: string;
html_url: string;
anonymous_id: string;
id?: number;
short_name?: string;
display_name?: string;
pronouns?: string;
}

View File

@@ -0,0 +1,4 @@
export interface IModuleItem {
name: string;
dueAt: string;
}

View File

@@ -0,0 +1,26 @@
import { z } from "zod";
export enum AssignmentSubmissionType {
ONLINE_TEXT_ENTRY = "online_text_entry",
ONLINE_UPLOAD = "online_upload",
ONLINE_QUIZ = "online_quiz",
DISCUSSION_TOPIC = "discussion_topic",
ONLINE_URL = "online_url",
NONE = "none",
}
export const zodAssignmentSubmissionType = z.enum([
AssignmentSubmissionType.ONLINE_TEXT_ENTRY,
AssignmentSubmissionType.ONLINE_UPLOAD,
AssignmentSubmissionType.ONLINE_QUIZ,
AssignmentSubmissionType.DISCUSSION_TOPIC,
AssignmentSubmissionType.ONLINE_URL,
AssignmentSubmissionType.NONE,
]);
export const AssignmentSubmissionTypeList: AssignmentSubmissionType[] = [
AssignmentSubmissionType.ONLINE_TEXT_ENTRY,
AssignmentSubmissionType.ONLINE_UPLOAD,
AssignmentSubmissionType.ONLINE_QUIZ,
AssignmentSubmissionType.DISCUSSION_TOPIC,
AssignmentSubmissionType.ONLINE_URL,
AssignmentSubmissionType.NONE,
] as const;

View File

@@ -0,0 +1,36 @@
import { IModuleItem } from "../IModuleItem";
import {
AssignmentSubmissionType,
zodAssignmentSubmissionType,
} from "./assignmentSubmissionType";
import { RubricItem, zodRubricItem } from "./rubricItem";
import { assignmentMarkdownParser } from "./utils/assignmentMarkdownParser";
import { assignmentMarkdownSerializer } from "./utils/assignmentMarkdownSerializer";
import { z } from "zod";
export interface LocalAssignment extends IModuleItem {
name: string;
description: string;
lockAt?: string; // 08/21/2023 23:59:00
dueAt: string; // 08/21/2023 23:59:00
localAssignmentGroupName?: string;
submissionTypes: AssignmentSubmissionType[];
allowedFileUploadExtensions: string[];
rubric: RubricItem[];
}
export const zodLocalAssignment = z.object({
name: z.string(),
description: z.string(),
lockAt: z.string().optional(),
dueAt: z.string(),
localAssignmentGroupName: z.string().optional(),
submissionTypes: zodAssignmentSubmissionType.array(),
allowedFileUploadExtensions: z.string().array(),
rubric: zodRubricItem.array(),
});
export const localAssignmentMarkdown = {
parseMarkdown: assignmentMarkdownParser.parseMarkdown,
toMarkdown: assignmentMarkdownSerializer.toMarkdown,
};

View File

@@ -0,0 +1,14 @@
import { z } from "zod";
export interface LocalAssignmentGroup {
canvasId?: number;
id: string;
name: string;
weight: number;
}
export const zodLocalAssignmentGroup = z.object({
canvasId: z.optional(z.number()),
id: z.string(),
name: z.string(),
weight: z.number(),
});

View File

@@ -0,0 +1,16 @@
import { z } from "zod";
export interface RubricItem {
label: string;
points: number;
}
export const zodRubricItem = z.object({
label: z.string(),
points: z.number(),
});
export const rubricItemIsExtraCredit = (item: RubricItem) => {
const extraCredit = "(extra credit)";
return item.label.toLowerCase().includes(extraCredit.toLowerCase());
};

View File

@@ -0,0 +1,146 @@
import {
verifyDateOrThrow,
verifyDateStringOrUndefined,
} from "../../utils/timeUtils";
import { AssignmentSubmissionType } from "../assignmentSubmissionType";
import { LocalAssignment } from "../localAssignment";
import { RubricItem } from "../rubricItem";
import { extractLabelValue } from "./markdownUtils";
const parseFileUploadExtensions = (input: string) => {
const allowedFileUploadExtensions: string[] = [];
const regex = /- (.+)/;
const words = input.split("AllowedFileUploadExtensions:");
if (words.length < 2) return allowedFileUploadExtensions;
const inputAfterSubmissionTypes = words[1];
const lines = inputAfterSubmissionTypes
.split("\n")
.map((line) => line.trim());
for (const line of lines) {
const match = regex.exec(line);
if (!match) {
if (line === "") continue;
else break;
}
allowedFileUploadExtensions.push(match[1].trim());
}
return allowedFileUploadExtensions;
};
const parseIndividualRubricItemMarkdown = (rawMarkdown: string) => {
const pointsPattern = /\s*-\s*(-?\d+(?:\.\d+)?)\s*pt(s)?:/;
const match = pointsPattern.exec(rawMarkdown);
if (!match) {
throw new Error(`Points not found: ${rawMarkdown}`);
}
const points = parseFloat(match[1]);
const label = rawMarkdown.split(": ").slice(1).join(": ");
const item: RubricItem = { points, label };
return item;
};
const parseSettings = (input: string) => {
const name = extractLabelValue(input, "Name");
const rawLockAt = extractLabelValue(input, "LockAt");
const rawDueAt = extractLabelValue(input, "DueAt");
const assignmentGroupName = extractLabelValue(input, "AssignmentGroupName");
const submissionTypes = parseSubmissionTypes(input);
const fileUploadExtensions = parseFileUploadExtensions(input);
const dueAt = verifyDateOrThrow(rawDueAt, "DueAt");
const lockAt = verifyDateStringOrUndefined(rawLockAt);
return {
name,
assignmentGroupName,
submissionTypes,
fileUploadExtensions,
dueAt,
lockAt,
};
};
const parseSubmissionTypes = (input: string): AssignmentSubmissionType[] => {
const submissionTypes: AssignmentSubmissionType[] = [];
const regex = /- (.+)/;
const words = input.split("SubmissionTypes:");
if (words.length < 2) return submissionTypes;
const inputAfterSubmissionTypes = words[1]; // doesn't consider other settings that follow...
const lines = inputAfterSubmissionTypes
.split("\n")
.map((line) => line.trim());
for (const line of lines) {
const match = regex.exec(line);
if (!match) {
if (line === "") continue;
else break;
}
const typeString = match[1].trim();
const type = Object.values(AssignmentSubmissionType).find(
(t) => t === typeString
);
if (type) {
submissionTypes.push(type);
} else {
console.warn(`Unknown submission type: ${typeString}`);
}
}
return submissionTypes;
};
const parseRubricMarkdown = (rawMarkdown: string) => {
if (!rawMarkdown.trim()) return [];
const lines = rawMarkdown.trim().split("\n");
return lines.map(parseIndividualRubricItemMarkdown);
};
export const assignmentMarkdownParser = {
parseRubricMarkdown,
parseMarkdown(input: string): LocalAssignment {
const settingsString = input.split("---")[0];
const {
name,
assignmentGroupName,
submissionTypes,
fileUploadExtensions,
dueAt,
lockAt,
} = parseSettings(settingsString);
const description = input
.split("---\n")
.slice(1)
.join("---\n")
.split("## Rubric")[0]
.trim();
const rubricString = input.split("## Rubric\n")[1];
const rubric = parseRubricMarkdown(rubricString);
const assignment: LocalAssignment = {
name: name.trim(),
localAssignmentGroupName: assignmentGroupName.trim(),
submissionTypes: submissionTypes,
allowedFileUploadExtensions: fileUploadExtensions,
dueAt: dueAt,
lockAt: lockAt,
rubric: rubric,
description: description,
};
return assignment;
},
};

View File

@@ -0,0 +1,54 @@
import { AssignmentSubmissionType } from "../assignmentSubmissionType";
import { LocalAssignment } from "../localAssignment";
import { RubricItem } from "../rubricItem";
const assignmentRubricToMarkdown = (assignment: LocalAssignment) => {
return assignment.rubric
.map((item: RubricItem) => {
const pointLabel = item.points > 1 ? "pts" : "pt";
return `- ${item.points}${pointLabel}: ${item.label}`;
})
.join("\n");
};
const settingsToMarkdown = (assignment: LocalAssignment) => {
const printableDueDate = assignment.dueAt.toString().replace("\u202F", " ");
const printableLockAt =
assignment.lockAt?.toString().replace("\u202F", " ") || "";
const submissionTypesMarkdown = assignment.submissionTypes
.map((submissionType: AssignmentSubmissionType) => `- ${submissionType}`)
.join("\n");
const allowedFileUploadExtensionsMarkdown =
assignment.allowedFileUploadExtensions
.map((fileExtension: string) => `- ${fileExtension}`)
.join("\n");
const settingsMarkdown = [
`Name: ${assignment.name}`,
`LockAt: ${printableLockAt}`,
`DueAt: ${printableDueDate}`,
`AssignmentGroupName: ${assignment.localAssignmentGroupName}`,
`SubmissionTypes:\n${submissionTypesMarkdown}`,
`AllowedFileUploadExtensions:\n${allowedFileUploadExtensionsMarkdown}`,
].join("\n");
return settingsMarkdown;
};
export const assignmentMarkdownSerializer = {
toMarkdown(assignment: LocalAssignment): string {
try {
const settingsMarkdown = settingsToMarkdown(assignment);
const rubricMarkdown = assignmentRubricToMarkdown(assignment);
const assignmentMarkdown = `${settingsMarkdown}\n---\n\n${assignment.description}\n\n## Rubric\n\n${rubricMarkdown}`;
return assignmentMarkdown;
} catch (e) {
console.log(assignment);
console.log("Error converting assignment to markdown");
throw e;
}
},
};

View File

@@ -0,0 +1,12 @@
import { RubricItem } from "../rubricItem";
export const assignmentPoints = (rubric: RubricItem[]) => {
const basePoints = rubric
.map((r) => {
if (r.label.toLowerCase().includes("(extra credit)")) return 0;
if (r.points < 0) return 0; // don't count negative points towards the point totals
return r.points;
})
.reduce((acc, current) => (current > 0 ? acc + current : acc), 0);
return basePoints;
};

View File

@@ -0,0 +1,10 @@
export const extractLabelValue = (input: string, label: string) => {
const pattern = new RegExp(`${label}: (.*?)\n`);
const match = pattern.exec(input);
if (match && match.length > 1 && match[1]) {
return match[1].trim();
}
return "";
};

View File

@@ -0,0 +1,17 @@
import { LocalAssignment } from "./assignment/localAssignment";
import { LocalCoursePage } from "./page/localCoursePage";
import { LocalQuiz } from "./quiz/localQuiz";
export type CourseItemType = "Assignment" | "Quiz" | "Page";
export type CourseItemReturnType<T extends CourseItemType> = T extends "Assignment"
? LocalAssignment
: T extends "Quiz"
? LocalQuiz
: LocalCoursePage;
export const typeToFolder = {
Assignment: "assignments",
Quiz: "quizzes",
Page: "pages",
} as const;

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
export interface Lecture {
name: string
date: string
content: string
}
export const zodLecture = z.object({
name: z.string(),
date: z.string(),
content: z.string(),
});

View File

@@ -0,0 +1,125 @@
import { z } from "zod";
import {
AssignmentSubmissionType,
zodAssignmentSubmissionType,
} from "./assignment/assignmentSubmissionType";
import {
LocalAssignmentGroup,
zodLocalAssignmentGroup,
} from "./assignment/localAssignmentGroup";
import { parse, stringify } from "yaml";
// export interface LocalCourse {
// modules: LocalModule[];
// settings: LocalCourseSettings;
// }
export interface SimpleTimeOnly {
hour: number;
minute: number;
}
export const zodSimpleTimeOnly = z.object({
hour: z.number().int().min(0).max(23), // hour should be an integer between 0 and 23
minute: z.number().int().min(0).max(59), // minute should be an integer between 0 and 59
});
export enum DayOfWeek {
Sunday = "Sunday",
Monday = "Monday",
Tuesday = "Tuesday",
Wednesday = "Wednesday",
Thursday = "Thursday",
Friday = "Friday",
Saturday = "Saturday",
}
export const zodDayOfWeek = z.enum([
DayOfWeek.Sunday,
DayOfWeek.Monday,
DayOfWeek.Tuesday,
DayOfWeek.Wednesday,
DayOfWeek.Thursday,
DayOfWeek.Friday,
DayOfWeek.Saturday,
]);
export interface LocalCourseSettings {
name: string;
assignmentGroups: LocalAssignmentGroup[];
daysOfWeek: DayOfWeek[];
canvasId: number;
startDate: string;
endDate: string;
defaultDueTime: SimpleTimeOnly;
defaultLockHoursOffset?: number;
defaultAssignmentSubmissionTypes: AssignmentSubmissionType[];
defaultFileUploadTypes: string[];
holidays: {
name: string;
days: string[];
}[];
assets: {
sourceUrl: string;
canvasUrl: string;
}[];
}
export const zodLocalCourseSettings = z.object({
name: z.string(),
assignmentGroups: zodLocalAssignmentGroup.array(),
daysOfWeek: zodDayOfWeek.array(),
canvasId: z.number(),
startDate: z.string(),
endDate: z.string(),
defaultDueTime: zodSimpleTimeOnly,
defaultLockHoursOffset: z.number().int().optional(),
defaultAssignmentSubmissionTypes: zodAssignmentSubmissionType.array(),
defaultFileUploadTypes: z.string().array(),
holidays: z
.object({
name: z.string(),
days: z.string().array(),
})
.array(),
assets: z
.object({
sourceUrl: z.string(),
canvasUrl: z.string(),
})
.array(),
});
export function getDayOfWeek(date: Date): DayOfWeek {
const dayIndex = date.getDay(); // Returns 0 for Sunday, 1 for Monday, etc.
return DayOfWeek[Object.keys(DayOfWeek)[dayIndex] as keyof typeof DayOfWeek];
}
export const localCourseYamlUtils = {
parseSettingYaml: (settingsString: string): LocalCourseSettings => {
const settings = parse(settingsString, {});
return lowercaseFirstLetter(settings);
},
settingsToYaml: (settings: Omit<LocalCourseSettings, "name">) => {
return stringify(settings);
},
};
function lowercaseFirstLetter<T>(obj: T): T {
if (obj === null || typeof obj !== "object") return obj as T;
if (Array.isArray(obj)) return obj.map(lowercaseFirstLetter) as unknown as T;
const result: Record<string, any> = {};
Object.keys(obj).forEach((key) => {
const value = (obj as Record<string, any>)[key];
const newKey = key.charAt(0).toLowerCase() + key.slice(1);
if (value && typeof value === "object") {
result[newKey] = lowercaseFirstLetter(value);
} else {
result[newKey] = value;
}
});
return result as T;
}

View File

@@ -0,0 +1,75 @@
import { LocalAssignment } from "./assignment/localAssignment";
import { IModuleItem } from "./IModuleItem";
import { LocalCoursePage } from "./page/localCoursePage";
import { LocalQuiz } from "./quiz/localQuiz";
import { getDateFromString } from "./utils/timeUtils";
export interface LocalModule {
name: string;
assignments: LocalAssignment[];
quizzes: LocalQuiz[];
pages: LocalCoursePage[];
}
export const LocalModuleUtils = {
getSortedModuleItems(module: LocalModule): IModuleItem[] {
return [...module.assignments, ...module.quizzes, ...module.pages].sort(
(a, b) =>
(getDateFromString(a.dueAt)?.getTime() ?? 0) -
(getDateFromString(b.dueAt)?.getTime() ?? 0)
);
},
equals(module1: LocalModule, module2: LocalModule): boolean {
return (
module1.name.toLowerCase() === module2.name.toLowerCase() &&
LocalModuleUtils.compareCollections(
module1.assignments.sort((a, b) => a.name.localeCompare(b.name)),
module2.assignments.sort((a, b) => a.name.localeCompare(b.name))
) &&
LocalModuleUtils.compareCollections(
module1.quizzes.sort((a, b) => a.name.localeCompare(b.name)),
module2.quizzes.sort((a, b) => a.name.localeCompare(b.name))
) &&
LocalModuleUtils.compareCollections(
module1.pages.sort((a, b) => a.name.localeCompare(b.name)),
module2.pages.sort((a, b) => a.name.localeCompare(b.name))
)
);
},
compareCollections<T>(first: T[], second: T[]): boolean {
if (first.length !== second.length) return false;
for (let i = 0; i < first.length; i++) {
if (JSON.stringify(first[i]) !== JSON.stringify(second[i])) return false;
}
return true;
},
getHashCode(module: LocalModule): number {
const hash = new Map<string, number>();
hash.set(module.name.toLowerCase(), 1);
LocalModuleUtils.addRangeToHash(
hash,
module.assignments.sort((a, b) => a.name.localeCompare(b.name))
);
LocalModuleUtils.addRangeToHash(
hash,
module.quizzes.sort((a, b) => a.name.localeCompare(b.name))
);
LocalModuleUtils.addRangeToHash(
hash,
module.pages.sort((a, b) => a.name.localeCompare(b.name))
);
return Array.from(hash.values()).reduce((acc, val) => acc + val, 0);
},
addRangeToHash<T>(hash: Map<string, number>, items: T[]): void {
for (const item of items) {
hash.set(JSON.stringify(item), 1);
}
},
};

View File

@@ -0,0 +1,43 @@
import { z } from "zod";
import { extractLabelValue } from "../assignment/utils/markdownUtils";
import { IModuleItem } from "../IModuleItem";
import { verifyDateOrThrow } from "../utils/timeUtils";
export interface LocalCoursePage extends IModuleItem {
name: string;
text: string;
dueAt: string;
}
export const zodLocalCoursePage = z.object({
name: z.string(),
text: z.string(),
dueAt: z.string(), // ISO 8601 date string
});
export const localPageMarkdownUtils = {
toMarkdown: (page: LocalCoursePage) => {
const printableDueDate = verifyDateOrThrow(
page.dueAt,
"page DueDateForOrdering"
);
const settingsMarkdown = `Name: ${page.name}\nDueDateForOrdering: ${printableDueDate}\n---\n`;
return settingsMarkdown + page.text;
},
parseMarkdown: (pageMarkdown: string) => {
const rawSettings = pageMarkdown.split("---")[0];
const name = extractLabelValue(rawSettings, "Name");
const rawDate = extractLabelValue(rawSettings, "DueDateForOrdering");
const dueAt = verifyDateOrThrow(rawDate, "page DueDateForOrdering");
const text = pageMarkdown.split("---\n")[1];
const page: LocalCoursePage = {
name,
dueAt,
text,
};
return page;
},
};

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import { IModuleItem } from "../IModuleItem";
import { LocalQuizQuestion, zodLocalQuizQuestion } from "./localQuizQuestion";
import { quizMarkdownUtils } from "./utils/quizMarkdownUtils";
export interface LocalQuiz extends IModuleItem {
name: string;
description: string;
password?: string;
lockAt?: string; // ISO 8601 date string
dueAt: string; // ISO 8601 date string
shuffleAnswers: boolean;
showCorrectAnswers: boolean;
oneQuestionAtATime: boolean;
localAssignmentGroupName?: string;
allowedAttempts: number;
questions: LocalQuizQuestion[];
}
export const zodLocalQuiz = z.object({
name: z.string(),
description: z.string(),
password: z.string().optional(),
lockAt: z.string().optional(),
dueAt: z.string(),
shuffleAnswers: z.boolean(),
showCorrectAnswers: z.boolean(),
oneQuestionAtATime: z.boolean(),
localAssignmentGroupName: z.string().optional(),
allowedAttempts: z.number(),
questions: zodLocalQuizQuestion.array(),
});
export const localQuizMarkdownUtils = {
parseMarkdown: quizMarkdownUtils.parseMarkdown,
toMarkdown: quizMarkdownUtils.toMarkdown,
};

View File

@@ -0,0 +1,38 @@
import { z } from "zod";
import {
LocalQuizQuestionAnswer,
zodLocalQuizQuestionAnswer,
} from "./localQuizQuestionAnswer";
export enum QuestionType {
MULTIPLE_ANSWERS = "multiple_answers",
MULTIPLE_CHOICE = "multiple_choice",
ESSAY = "essay",
SHORT_ANSWER = "short_answer",
MATCHING = "matching",
NONE = "",
}
export const zodQuestionType = z.enum([
QuestionType.MULTIPLE_ANSWERS,
QuestionType.MULTIPLE_CHOICE,
QuestionType.ESSAY,
QuestionType.SHORT_ANSWER,
QuestionType.MATCHING,
QuestionType.NONE,
]);
export interface LocalQuizQuestion {
text: string;
questionType: QuestionType;
points: number;
answers: LocalQuizQuestionAnswer[];
matchDistractors: string[];
}
export const zodLocalQuizQuestion = z.object({
text: z.string(),
questionType: zodQuestionType,
points: z.number(),
answers: zodLocalQuizQuestionAnswer.array(),
matchDistractors: z.array(z.string()),
});

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
export interface LocalQuizQuestionAnswer {
correct: boolean;
text: string;
matchedText?: string;
}
export const zodLocalQuizQuestionAnswer = z.object({
correct: z.boolean(),
text: z.string(),
matchedText: z.string().optional(),
});

View File

@@ -0,0 +1,154 @@
import {
verifyDateOrThrow,
verifyDateStringOrUndefined,
} from "../../utils/timeUtils";
import { LocalQuiz } from "../localQuiz";
import { quizQuestionMarkdownUtils } from "./quizQuestionMarkdownUtils";
const extractLabelValue = (input: string, label: string): string => {
const pattern = new RegExp(`${label}: (.*?)\n`);
const match = pattern.exec(input);
return match ? match[1].trim() : "";
};
const extractDescription = (input: string): string => {
const pattern = new RegExp("Description: (.*?)$", "s");
const match = pattern.exec(input);
return match ? match[1].trim() : "";
};
const parseBooleanOrThrow = (value: string, label: string): boolean => {
if (value.toLowerCase() === "true") return true;
if (value.toLowerCase() === "false") return false;
throw new Error(`Error with ${label}: ${value}`);
};
const parseBooleanOrDefault = (
value: string,
label: string,
defaultValue: boolean
): boolean => {
if (value.toLowerCase() === "true") return true;
if (value.toLowerCase() === "false") return false;
return defaultValue;
};
const parseNumberOrThrow = (value: string, label: string): number => {
const parsed = parseInt(value, 10);
if (isNaN(parsed)) {
throw new Error(`Error with ${label}: ${value}`);
}
return parsed;
};
const getQuizWithOnlySettings = (settings: string): LocalQuiz => {
const name = extractLabelValue(settings, "Name");
const rawShuffleAnswers = extractLabelValue(settings, "ShuffleAnswers");
const shuffleAnswers = parseBooleanOrThrow(
rawShuffleAnswers,
"ShuffleAnswers"
);
const password = extractLabelValue(settings, "Password") || undefined;
const rawShowCorrectAnswers = extractLabelValue(
settings,
"ShowCorrectAnswers"
);
const showCorrectAnswers = parseBooleanOrDefault(
rawShowCorrectAnswers,
"ShowCorrectAnswers",
true
);
const rawOneQuestionAtATime = extractLabelValue(
settings,
"OneQuestionAtATime"
);
const oneQuestionAtATime = parseBooleanOrThrow(
rawOneQuestionAtATime,
"OneQuestionAtATime"
);
const rawAllowedAttempts = extractLabelValue(settings, "AllowedAttempts");
const allowedAttempts = parseNumberOrThrow(
rawAllowedAttempts,
"AllowedAttempts"
);
const rawDueAt = extractLabelValue(settings, "DueAt");
const dueAt = verifyDateOrThrow(rawDueAt, "DueAt");
const rawLockAt = extractLabelValue(settings, "LockAt");
const lockAt = verifyDateStringOrUndefined(rawLockAt);
const description = extractDescription(settings);
const localAssignmentGroupName = extractLabelValue(
settings,
"AssignmentGroup"
);
const quiz: LocalQuiz = {
name,
description,
password,
lockAt,
dueAt,
shuffleAnswers,
showCorrectAnswers,
oneQuestionAtATime,
localAssignmentGroupName,
allowedAttempts,
questions: [],
};
return quiz;
};
export const quizMarkdownUtils = {
toMarkdown(quiz: LocalQuiz): string {
if (!quiz) {
throw Error(`quiz was undefined, cannot parse markdown`);
}
if (
typeof quiz.questions === "undefined" ||
typeof quiz.oneQuestionAtATime === "undefined"
) {
console.log("quiz is probably not a quiz", quiz);
throw Error(`quiz ${quiz.name} is probably not a quiz`);
}
const questionMarkdownArray = quiz.questions.map((q) =>
quizQuestionMarkdownUtils.toMarkdown(q)
);
const questionDelimiter = "\n\n---\n\n";
const questionMarkdown = questionMarkdownArray.join(questionDelimiter);
return `Name: ${quiz.name}
LockAt: ${quiz.lockAt ?? ""}
DueAt: ${quiz.dueAt}
Password: ${quiz.password ?? ""}
ShuffleAnswers: ${quiz.shuffleAnswers.toString().toLowerCase()}
ShowCorrectAnswers: ${quiz.showCorrectAnswers.toString().toLowerCase()}
OneQuestionAtATime: ${quiz.oneQuestionAtATime.toString().toLowerCase()}
AssignmentGroup: ${quiz.localAssignmentGroupName}
AllowedAttempts: ${quiz.allowedAttempts}
Description: ${quiz.description}
---
${questionMarkdown}`;
},
parseMarkdown(input: string): LocalQuiz {
const splitInput = input.split("---\n");
const settings = splitInput[0];
const quizWithoutQuestions = getQuizWithOnlySettings(settings);
const rawQuestions = splitInput.slice(1);
const questions = rawQuestions
.filter((str) => str.trim().length > 0)
.map((q, i) => quizQuestionMarkdownUtils.parseMarkdown(q, i));
return {
...quizWithoutQuestions,
questions,
};
},
};

View File

@@ -0,0 +1,39 @@
import { QuestionType } from "../localQuizQuestion";
import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer";
export const quizQuestionAnswerMarkdownUtils = {
// getHtmlText(): string {
// return MarkdownService.render(this.text);
// }
parseMarkdown(input: string, questionType: string): LocalQuizQuestionAnswer {
const isCorrect = input.startsWith("*") || input[1] === "*";
if (questionType === QuestionType.MATCHING) {
const matchingPattern = /^\^ ?/;
const textWithoutMatchDelimiter = input
.replace(matchingPattern, "")
.trim();
const [text, ...matchedParts] = textWithoutMatchDelimiter.split("-");
const answer: LocalQuizQuestionAnswer = {
correct: true,
text: text.trim(),
matchedText: matchedParts.join("-").trim(),
};
return answer;
}
const startingQuestionPattern = /^(\*?[a-z]?\))|\[\s*\]|\[\*\]|\^ /;
let replaceCount = 0;
const text = input
.replace(startingQuestionPattern, (m) => (replaceCount++ === 0 ? "" : m))
.trim();
const answer: LocalQuizQuestionAnswer = {
correct: isCorrect,
text: text,
};
return answer;
},
};

View File

@@ -0,0 +1,241 @@
import { LocalQuiz } from "../localQuiz";
import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion";
import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer";
import { quizQuestionAnswerMarkdownUtils } from "./quizQuestionAnswerMarkdownUtils";
const _validFirstAnswerDelimiters = [
"*a)",
"a)",
"*)",
")",
"[ ]",
"[]",
"[*]",
"^",
];
const _multipleChoicePrefix = ["a)", "*a)", "*)", ")"];
const _multipleAnswerPrefix = ["[ ]", "[*]", "[]"];
const getAnswerStringsWithMultilineSupport = (
linesWithoutPoints: string[],
questionIndex: number
) => {
const indexOfAnswerStart = linesWithoutPoints.findIndex((l) =>
_validFirstAnswerDelimiters.some((prefix) =>
l.trimStart().startsWith(prefix)
)
);
if (indexOfAnswerStart === -1) {
const debugLine = linesWithoutPoints.find((l) => l.trim().length > 0);
throw Error(
`question ${
questionIndex + 1
}: no answers when detecting question type on ${debugLine}`
);
}
const answerLinesRaw = linesWithoutPoints.slice(indexOfAnswerStart);
const answerStartPattern = /^(\*?[a-z]?\))|(?<!\S)\[\s*\]|\[\*\]|\^/;
const answerLines = answerLinesRaw.reduce((acc: string[], line: string) => {
const isNewAnswer = answerStartPattern.test(line);
if (isNewAnswer) {
acc.push(line);
} else if (acc.length !== 0) {
acc[acc.length - 1] += "\n" + line;
} else {
acc.push(line);
}
return acc;
}, []);
return answerLines;
};
const getQuestionType = (
linesWithoutPoints: string[],
questionIndex: number
): QuestionType => {
if (linesWithoutPoints.length === 0) return QuestionType.NONE;
if (
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() === "essay"
)
return QuestionType.ESSAY;
if (
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() ===
"short answer"
)
return QuestionType.SHORT_ANSWER;
if (
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() ===
"short_answer"
)
return QuestionType.SHORT_ANSWER;
const answerLines = getAnswerStringsWithMultilineSupport(
linesWithoutPoints,
questionIndex
);
const firstAnswerLine = answerLines[0];
const isMultipleChoice = _multipleChoicePrefix.some((prefix) =>
firstAnswerLine.startsWith(prefix)
);
if (isMultipleChoice) return QuestionType.MULTIPLE_CHOICE;
const isMultipleAnswer = _multipleAnswerPrefix.some((prefix) =>
firstAnswerLine.startsWith(prefix)
);
if (isMultipleAnswer) return QuestionType.MULTIPLE_ANSWERS;
const isMatching = firstAnswerLine.startsWith("^");
if (isMatching) return QuestionType.MATCHING;
return QuestionType.NONE;
};
const getAnswers = (
linesWithoutPoints: string[],
questionIndex: number,
questionType: string
): LocalQuizQuestionAnswer[] => {
const answerLines = getAnswerStringsWithMultilineSupport(
linesWithoutPoints,
questionIndex
);
const answers = answerLines.map((a, i) =>
quizQuestionAnswerMarkdownUtils.parseMarkdown(a, questionType)
);
return answers;
};
const getAnswerMarkdown = (
question: LocalQuizQuestion,
answer: LocalQuizQuestionAnswer,
index: number
): string => {
const multilineMarkdownCompatibleText = answer.text.startsWith("```")
? "\n" + answer.text
: answer.text;
if (question.questionType === "multiple_answers") {
const correctIndicator = answer.correct ? "*" : " ";
const questionTypeIndicator = `[${correctIndicator}] `;
return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`;
} else if (question.questionType === "matching") {
return `^ ${answer.text} - ${answer.matchedText}`;
} else {
const questionLetter = String.fromCharCode(97 + index);
const correctIndicator = answer.correct ? "*" : "";
const questionTypeIndicator = `${correctIndicator}${questionLetter}) `;
return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`;
}
};
export const quizQuestionMarkdownUtils = {
toMarkdown(question: LocalQuizQuestion): string {
const answerArray = question.answers.map((a, i) =>
getAnswerMarkdown(question, a, i)
);
const distractorText =
question.questionType === QuestionType.MATCHING
? question.matchDistractors?.map((d) => `\n^ - ${d}`).join("") ?? ""
: "";
const answersText = answerArray.join("\n");
const questionTypeIndicator =
question.questionType === "essay" ||
question.questionType === "short_answer"
? question.questionType
: "";
return `Points: ${question.points}\n${question.text}\n${answersText}${distractorText}${questionTypeIndicator}`;
},
parseMarkdown(input: string, questionIndex: number): LocalQuizQuestion {
const lines = input.trim().split("\n");
const firstLineIsPoints = lines[0].toLowerCase().includes("points: ");
const textHasPoints =
lines.length > 0 &&
lines[0].includes(": ") &&
lines[0].split(": ").length > 1 &&
!isNaN(parseFloat(lines[0].split(": ")[1]));
const points =
firstLineIsPoints && textHasPoints
? parseFloat(lines[0].split(": ")[1])
: 1;
const linesWithoutPoints = firstLineIsPoints ? lines.slice(1) : lines;
const { linesWithoutAnswers } = linesWithoutPoints.reduce(
({ linesWithoutAnswers, taking }, currentLine) => {
if (!taking)
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
const lineIsAnswer = _validFirstAnswerDelimiters.some((prefix) =>
currentLine.trimStart().startsWith(prefix)
);
if (lineIsAnswer)
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
return {
linesWithoutAnswers: [...linesWithoutAnswers, currentLine],
taking: true,
};
},
{ linesWithoutAnswers: [] as string[], taking: true }
);
const questionType = getQuestionType(linesWithoutPoints, questionIndex);
const questionTypesWithoutAnswers = [
"essay",
"short answer",
"short_answer",
];
const descriptionLines = questionTypesWithoutAnswers.includes(
questionType.toLowerCase()
)
? linesWithoutAnswers
.slice(0, linesWithoutPoints.length)
.filter(
(line, index) =>
!questionTypesWithoutAnswers.includes(line.toLowerCase())
)
: linesWithoutAnswers;
const description = descriptionLines.join("\n");
const typesWithAnswers = [
"multiple_choice",
"multiple_answers",
"matching",
];
const answers = typesWithAnswers.includes(questionType)
? getAnswers(linesWithoutPoints, questionIndex, questionType)
: [];
const answersWithoutDistractors =
questionType === QuestionType.MATCHING
? answers.filter((a) => a.text)
: answers;
const distractors =
questionType === QuestionType.MATCHING
? answers.filter((a) => !a.text).map((a) => a.matchedText ?? "")
: [];
const question: LocalQuizQuestion = {
text: description,
questionType,
points,
answers: answersWithoutDistractors,
matchDistractors: distractors,
};
return question;
},
};

View File

@@ -0,0 +1,159 @@
import { describe, it, expect } from "vitest";
import { LocalAssignment } from "../assignment/localAssignment";
import { AssignmentSubmissionType } from "../assignment/assignmentSubmissionType";
import { assignmentMarkdownSerializer } from "../assignment/utils/assignmentMarkdownSerializer";
import { assignmentMarkdownParser } from "../assignment/utils/assignmentMarkdownParser";
describe("AssignmentMarkdownTests", () => {
it("can parse assignment settings", () => {
const assignment: LocalAssignment = {
name: "test assignment",
description: "here is the description",
dueAt: "08/21/2023 23:59:00",
lockAt: "08/21/2023 23:59:00",
submissionTypes: [AssignmentSubmissionType.ONLINE_UPLOAD],
localAssignmentGroupName: "Final Project",
rubric: [
{ points: 4, label: "do task 1" },
{ points: 2, label: "do task 2" },
],
allowedFileUploadExtensions: [],
};
const assignmentMarkdown =
assignmentMarkdownSerializer.toMarkdown(assignment);
const parsedAssignment =
assignmentMarkdownParser.parseMarkdown(assignmentMarkdown);
expect(parsedAssignment).toEqual(assignment);
});
it("assignment with empty rubric can be parsed", () => {
const assignment: LocalAssignment = {
name: "test assignment",
description: "here is the description",
dueAt: "08/21/2023 23:59:00",
lockAt: "08/21/2023 23:59:00",
submissionTypes: [AssignmentSubmissionType.ONLINE_UPLOAD],
localAssignmentGroupName: "Final Project",
rubric: [],
allowedFileUploadExtensions: [],
};
const assignmentMarkdown =
assignmentMarkdownSerializer.toMarkdown(assignment);
const parsedAssignment =
assignmentMarkdownParser.parseMarkdown(assignmentMarkdown);
expect(parsedAssignment).toEqual(assignment);
});
it("assignment with empty submission types can be parsed", () => {
const assignment: LocalAssignment = {
name: "test assignment",
description: "here is the description",
dueAt: "08/21/2023 23:59:00",
lockAt: "08/21/2023 23:59:00",
submissionTypes: [],
localAssignmentGroupName: "Final Project",
rubric: [
{ points: 4, label: "do task 1" },
{ points: 2, label: "do task 2" },
],
allowedFileUploadExtensions: [],
};
const assignmentMarkdown =
assignmentMarkdownSerializer.toMarkdown(assignment);
const parsedAssignment =
assignmentMarkdownParser.parseMarkdown(assignmentMarkdown);
expect(parsedAssignment).toEqual(assignment);
});
it("assignment without lockAt date can be parsed", () => {
const assignment: LocalAssignment = {
name: "test assignment",
description: "here is the description",
dueAt: "08/21/2023 23:59:00",
lockAt: undefined,
submissionTypes: [],
localAssignmentGroupName: "Final Project",
rubric: [
{ points: 4, label: "do task 1" },
{ points: 2, label: "do task 2" },
],
allowedFileUploadExtensions: [],
};
const assignmentMarkdown =
assignmentMarkdownSerializer.toMarkdown(assignment);
const parsedAssignment =
assignmentMarkdownParser.parseMarkdown(assignmentMarkdown);
expect(parsedAssignment).toEqual(assignment);
});
it("assignment without description can be parsed", () => {
const assignment: LocalAssignment = {
name: "test assignment",
description: "",
dueAt: "08/21/2023 23:59:00",
lockAt: "08/21/2023 23:59:00",
submissionTypes: [],
localAssignmentGroupName: "Final Project",
rubric: [
{ points: 4, label: "do task 1" },
{ points: 2, label: "do task 2" },
],
allowedFileUploadExtensions: [],
};
const assignmentMarkdown =
assignmentMarkdownSerializer.toMarkdown(assignment);
const parsedAssignment =
assignmentMarkdownParser.parseMarkdown(assignmentMarkdown);
expect(parsedAssignment).toEqual(assignment);
});
it("assignments can have three dashes", () => {
const assignment: LocalAssignment = {
name: "test assignment",
description: "test assignment\n---\nsomestuff",
dueAt: "08/21/2023 23:59:00",
lockAt: "08/21/2023 23:59:00",
submissionTypes: [],
localAssignmentGroupName: "Final Project",
rubric: [],
allowedFileUploadExtensions: [],
};
const assignmentMarkdown =
assignmentMarkdownSerializer.toMarkdown(assignment);
const parsedAssignment =
assignmentMarkdownParser.parseMarkdown(assignmentMarkdown);
expect(parsedAssignment).toEqual(assignment);
});
it("assignments can restrict upload types", () => {
const assignment: LocalAssignment = {
name: "test assignment",
description: "here is the description",
dueAt: "08/21/2023 23:59:00",
lockAt: "08/21/2023 23:59:00",
submissionTypes: [AssignmentSubmissionType.ONLINE_UPLOAD],
allowedFileUploadExtensions: ["pdf", "txt"],
localAssignmentGroupName: "Final Project",
rubric: [],
};
const assignmentMarkdown =
assignmentMarkdownSerializer.toMarkdown(assignment);
const parsedAssignment =
assignmentMarkdownParser.parseMarkdown(assignmentMarkdown);
expect(parsedAssignment).toEqual(assignment);
});
});

View File

@@ -0,0 +1,18 @@
import { describe, it, expect } from "vitest";
import { LocalCoursePage, localPageMarkdownUtils } from "../page/localCoursePage";
describe("PageMarkdownTests", () => {
it("can parse page", () => {
const page: LocalCoursePage = {
name: "test title",
text: "test text content",
dueAt: "07/09/2024 23:59:00",
};
const pageMarkdownString = localPageMarkdownUtils.toMarkdown(page);
const parsedPage = localPageMarkdownUtils.parseMarkdown(pageMarkdownString);
expect(parsedPage).toEqual(page);
});
});

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils";
describe("Matching Answer Error Messages", () => {
it("can parse matching question", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description:
---
question without answer
`;
expect(() => quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz)).toThrowError(
/question type/
);
});
});

View File

@@ -0,0 +1,134 @@
import { describe, it, expect } from "vitest";
import { QuestionType } from "../../quiz/localQuizQuestion";
import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils";
import { quizQuestionMarkdownUtils } from "@/models/local/quiz/utils/quizQuestionMarkdownUtils";
describe("MatchingTests", () => {
it("can parse matching question", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description:
---
Match the following terms & definitions
^ statement - a single command to be executed
^ identifier - name of a variable
^ keyword - reserved word that has special meaning in a program (e.g. class, void, static, etc.)
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const firstQuestion = quiz.questions[0];
expect(firstQuestion.questionType).toBe(QuestionType.MATCHING);
expect(firstQuestion.text).not.toContain("statement");
expect(firstQuestion.answers[0].matchedText).toBe(
"a single command to be executed"
);
});
it("can create markdown for matching question", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description:
---
Match the following terms & definitions
^ statement - a single command to be executed
^ identifier - name of a variable
^ keyword - reserved word that has special meaning in a program (e.g. class, void, static, etc.)
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const questionMarkdown = quizQuestionMarkdownUtils.toMarkdown(
quiz.questions[0]
);
const expectedMarkdown = `Points: 1
Match the following terms & definitions
^ statement - a single command to be executed
^ identifier - name of a variable
^ keyword - reserved word that has special meaning in a program (e.g. class, void, static, etc.)`;
expect(questionMarkdown).toContain(expectedMarkdown);
});
it("whitespace is optional", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description:
---
Match the following terms & definitions
^statement - a single command to be executed
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
expect(quiz.questions[0].answers[0].text).toBe("statement");
});
it("can have distractors", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description:
---
Match the following terms & definitions
^ statement - a single command to be executed
^ - this is the distractor
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
expect(quiz.questions[0].matchDistractors).toEqual([
"this is the distractor",
]);
});
it("can have distractors and be persisted", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description:
---
Match the following terms & definitions
^ statement - a single command to be executed
^ - this is the distractor
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
expect(quizMarkdown).toContain(
"^ statement - a single command to be executed\n^ - this is the distractor"
);
});
});

View File

@@ -0,0 +1,181 @@
import { describe, it, expect } from "vitest";
import { LocalQuiz } from "../../quiz/localQuiz";
import { QuestionType } from "../../quiz/localQuizQuestion";
import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils";
import { quizQuestionMarkdownUtils } from "@/models/local/quiz/utils/quizQuestionMarkdownUtils";
describe("MultipleAnswersTests", () => {
it("quiz markdown includes multiple answer question", () => {
const quiz: LocalQuiz = {
name: "Test Quiz",
description: "desc",
dueAt: "08/21/2023 23:59:00",
lockAt: "08/21/2023 23:59:00",
shuffleAnswers: true,
oneQuestionAtATime: false,
showCorrectAnswers: false,
localAssignmentGroupName: "someId",
allowedAttempts: -1,
questions: [
{
text: "oneline question",
points: 1,
questionType: QuestionType.MULTIPLE_ANSWERS,
answers: [
{ correct: true, text: "true" },
{ correct: true, text: "false" },
{ correct: false, text: "neither" },
],
matchDistractors: [],
},
],
};
const markdown = quizMarkdownUtils.toMarkdown(quiz);
const expectedQuestionString = `Points: 1
oneline question
[*] true
[*] false
[ ] neither`;
expect(markdown).toContain(expectedQuestionString);
});
it("can parse question with multiple answers", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Which events are triggered when the user clicks on an input field?
[*] click
[*] focus
[*] mousedown
[ ] submit
[ ] change
[ ] mouseout
[ ] keydown
---
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const firstQuestion = quiz.questions[0];
expect(firstQuestion.points).toBe(1);
expect(firstQuestion.questionType).toBe(QuestionType.MULTIPLE_ANSWERS);
expect(firstQuestion.text).toContain(
"Which events are triggered when the user clicks on an input field?"
);
expect(firstQuestion.answers[0].text).toBe("click");
expect(firstQuestion.answers[0].correct).toBe(true);
expect(firstQuestion.answers[3].correct).toBe(false);
expect(firstQuestion.answers[3].text).toBe("submit");
});
it("can parse question with multiple answers without a space in false answers", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Which events are triggered when the user clicks on an input field?
[*] click
[] submit
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const firstQuestion = quiz.questions[0];
expect(firstQuestion.answers.length).toBe(2);
expect(firstQuestion.answers[0].correct).toBe(true);
expect(firstQuestion.answers[1].correct).toBe(false);
});
it("can parse question with multiple answers without a space in false answers other example", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Points: 1
Which tool(s) will let you: create a database migration or reverse-engineer an existing database
[] swagger
[] a .http file
[*] dotnet ef command line interface
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const firstQuestion = quiz.questions[0];
expect(firstQuestion.answers.length).toBe(3);
expect(firstQuestion.answers[0].correct).toBe(false);
expect(firstQuestion.answers[1].correct).toBe(false);
expect(firstQuestion.answers[2].correct).toBe(true);
});
it("can use braces in answer for multiple answer", () => {
const rawMarkdownQuestion = `
Which events are triggered when the user clicks on an input field?
[*] \`int[] theThing()\`
[ ] keydown
`;
const question = quizQuestionMarkdownUtils.parseMarkdown(
rawMarkdownQuestion,
0
);
expect(question.answers[0].text).toBe("`int[] theThing()`");
expect(question.answers.length).toBe(2);
});
it("can use braces in answer for multiple answer with multiline", () => {
const rawMarkdownQuestion = `
Which events are triggered when the user clicks on an input field?
[*]
\`\`\`
int[] myNumbers = new int[] { };
DoSomething(ref myNumbers);
static void DoSomething(ref int[] numbers)
{
// do something
}
\`\`\`
`;
const question = quizQuestionMarkdownUtils.parseMarkdown(
rawMarkdownQuestion,
0
);
expect(question.answers[0].text).toBe(`\`\`\`
int[] myNumbers = new int[] { };
DoSomething(ref myNumbers);
static void DoSomething(ref int[] numbers)
{
// do something
}
\`\`\``);
expect(question.answers.length).toBe(1);
});
});

View File

@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { LocalQuiz } from "../../quiz/localQuiz";
import { LocalQuizQuestion, QuestionType } from "../../quiz/localQuizQuestion";
import { LocalQuizQuestionAnswer } from "../../quiz/localQuizQuestionAnswer";
import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils";
import { quizQuestionMarkdownUtils } from "@/models/local/quiz/utils/quizQuestionMarkdownUtils";
describe("MultipleChoiceTests", () => {
it("quiz markdown includes multiple choice question", () => {
const quiz: LocalQuiz = {
name: "Test Quiz",
description: "desc",
dueAt: "08/21/2023 23:59:00",
lockAt: "08/21/2023 23:59:00",
shuffleAnswers: true,
oneQuestionAtATime: false,
showCorrectAnswers: false,
localAssignmentGroupName: "someId",
allowedAttempts: -1,
questions: [
{
points: 2,
text: `
\`some type\` of question
with many
\`\`\`
lines
\`\`\`
`,
questionType: QuestionType.MULTIPLE_CHOICE,
answers: [
{ correct: true, text: "true" },
{ correct: false, text: "false\n\nendline" },
],
matchDistractors: [],
},
],
};
const markdown = quizMarkdownUtils.toMarkdown(quiz);
const expectedQuestionString = `
Points: 2
\`some type\` of question
with many
\`\`\`
lines
\`\`\`
*a) true
b) false
endline`;
expect(markdown).toContain(expectedQuestionString);
});
it("letter optional for multiple choice", () => {
const questionMarkdown = `
Points: 2
\`some type\` of question
*) true
) false
`;
const question = quizQuestionMarkdownUtils.parseMarkdown(
questionMarkdown,
0
);
expect(question.answers.length).toBe(2);
});
});

View File

@@ -0,0 +1,198 @@
import { describe, it, expect } from "vitest";
import { LocalQuiz } from "../../quiz/localQuiz";
import { quizMarkdownUtils } from "../../quiz/utils/quizMarkdownUtils";
import { QuestionType } from "@/models/local/quiz/localQuizQuestion";
// Test suite for deterministic checks on LocalQuiz
describe("QuizDeterministicChecks", () => {
it("SerializationIsDeterministic_EmptyQuiz", () => {
const quiz: LocalQuiz = {
name: "Test Quiz",
description: "quiz description",
lockAt: "08/21/2023 23:59:00",
dueAt: "08/21/2023 23:59:00",
shuffleAnswers: true,
oneQuestionAtATime: true,
localAssignmentGroupName: "Assignments",
questions: [],
allowedAttempts: -1,
showCorrectAnswers: true,
};
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown);
expect(parsedQuiz).toEqual(quiz);
});
it("SerializationIsDeterministic_ShowCorrectAnswers", () => {
const quiz: LocalQuiz = {
name: "Test Quiz",
description: "quiz description",
lockAt: "08/21/2023 23:59:00",
dueAt: "08/21/2023 23:59:00",
showCorrectAnswers: false,
shuffleAnswers: true,
oneQuestionAtATime: true,
localAssignmentGroupName: "Assignments",
questions: [],
allowedAttempts: -1,
};
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown);
expect(parsedQuiz).toEqual(quiz);
});
it("SerializationIsDeterministic_ShortAnswer", () => {
const quiz: LocalQuiz = {
name: "Test Quiz",
description: "quiz description",
lockAt: "08/21/2023 23:59:00",
dueAt: "08/21/2023 23:59:00",
shuffleAnswers: true,
oneQuestionAtATime: true,
localAssignmentGroupName: "Assignments",
questions: [
{
text: "test short answer",
questionType: QuestionType.SHORT_ANSWER,
points: 1,
answers: [],
matchDistractors: [],
},
],
allowedAttempts: -1,
showCorrectAnswers: true,
};
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown);
expect(parsedQuiz).toEqual(quiz);
});
it("SerializationIsDeterministic_Essay", () => {
const quiz: LocalQuiz = {
name: "Test Quiz",
description: "quiz description",
lockAt: "08/21/2023 23:59:00",
dueAt: "08/21/2023 23:59:00",
shuffleAnswers: true,
oneQuestionAtATime: true,
localAssignmentGroupName: "Assignments",
questions: [
{
text: "test essay",
questionType: QuestionType.ESSAY,
points: 1,
matchDistractors: [],
answers: [],
},
],
allowedAttempts: -1,
showCorrectAnswers: true,
};
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown);
expect(parsedQuiz).toEqual(quiz);
});
it("SerializationIsDeterministic_MultipleAnswer", () => {
const quiz: LocalQuiz = {
name: "Test Quiz",
description: "quiz description",
lockAt: "08/21/2023 23:59:00",
dueAt: "08/21/2023 23:59:00",
shuffleAnswers: true,
oneQuestionAtATime: true,
localAssignmentGroupName: "Assignments",
questions: [
{
text: "test multiple answer",
questionType: QuestionType.MULTIPLE_ANSWERS,
points: 1,
matchDistractors: [],
answers: [
{ text: "yes", correct: true },
{ text: "no", correct: true },
],
},
],
allowedAttempts: -1,
showCorrectAnswers: true,
};
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown);
expect(parsedQuiz).toEqual(quiz);
});
it("SerializationIsDeterministic_MultipleChoice", () => {
const quiz: LocalQuiz = {
name: "Test Quiz",
description: "quiz description",
lockAt: "08/21/2023 23:59:00",
dueAt: "08/21/2023 23:59:00",
shuffleAnswers: true,
oneQuestionAtATime: true,
password: undefined,
localAssignmentGroupName: "Assignments",
questions: [
{
text: "test multiple choice",
questionType: QuestionType.MULTIPLE_CHOICE,
points: 1,
matchDistractors: [],
answers: [
{ text: "yes", correct: true },
{ text: "no", correct: false },
],
},
],
allowedAttempts: -1,
showCorrectAnswers: true,
};
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown);
expect(parsedQuiz).toEqual(quiz);
});
it("SerializationIsDeterministic_Matching", () => {
const quiz: LocalQuiz = {
name: "Test Quiz",
description: "quiz description",
lockAt: "08/21/2023 23:59:00",
dueAt: "08/21/2023 23:59:00",
shuffleAnswers: true,
oneQuestionAtATime: true,
password: undefined,
localAssignmentGroupName: "Assignments",
questions: [
{
text: "test matching",
questionType: QuestionType.MATCHING,
points: 1,
matchDistractors: [],
answers: [
{ text: "yes", correct: true, matchedText: "testing yes" },
{ text: "no", correct: true, matchedText: "testing no" },
],
},
],
allowedAttempts: -1,
showCorrectAnswers: true,
};
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown);
expect(parsedQuiz).toEqual(quiz);
});
});

View File

@@ -0,0 +1,256 @@
import { describe, it, expect } from "vitest";
import { LocalQuiz } from "../../quiz/localQuiz";
import { quizMarkdownUtils } from "../../quiz/utils/quizMarkdownUtils";
import { QuestionType } from "@/models/local/quiz/localQuizQuestion";
import { quizQuestionMarkdownUtils } from "@/models/local/quiz/utils/quizQuestionMarkdownUtils";
// Test suite for QuizMarkdown
describe("QuizMarkdownTests", () => {
it("can serialize quiz to markdown", () => {
const quiz: LocalQuiz = {
name: "Test Quiz",
description: `
# quiz description
this is my description in markdown
\`here is code\`
`,
lockAt: new Date(8640000000000000).toISOString(), // DateTime.MaxValue equivalent in TypeScript
dueAt: new Date(8640000000000000).toISOString(),
shuffleAnswers: true,
oneQuestionAtATime: false,
localAssignmentGroupName: "someId",
allowedAttempts: -1,
showCorrectAnswers: false,
questions: [],
};
const markdown = quizMarkdownUtils.toMarkdown(quiz);
expect(markdown).toContain("Name: Test Quiz");
expect(markdown).toContain(quiz.description);
expect(markdown).toContain("ShuffleAnswers: true");
expect(markdown).toContain("OneQuestionAtATime: false");
expect(markdown).toContain("AssignmentGroup: someId");
expect(markdown).toContain("AllowedAttempts: -1");
});
it("can parse markdown quiz with no questions", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const expectedDescription = `
this is the
multi line
description`;
expect(quiz.name).toBe("Test Quiz");
expect(quiz.shuffleAnswers).toBe(true);
expect(quiz.oneQuestionAtATime).toBe(false);
expect(quiz.allowedAttempts).toBe(-1);
expect(quiz.description.trim()).toBe(expectedDescription.trim());
});
it("can parse markdown quiz with password", () => {
const password = "this-is-the-password";
const rawMarkdownQuiz = `
Name: Test Quiz
Password: ${password}
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
expect(quiz.password).toBe(password);
});
it("can parse markdown quiz and configure to show correct answers", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
ShowCorrectAnswers: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
expect(quiz.showCorrectAnswers).toBe(false);
});
it("can parse quiz with questions", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Points: 2
\`some type\` of question
with many
\`\`\`
lines
\`\`\`
*a) true
b) false
endline`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const firstQuestion = quiz.questions[0];
expect(firstQuestion.questionType).toBe(QuestionType.MULTIPLE_CHOICE);
expect(firstQuestion.points).toBe(2);
expect(firstQuestion.text).toContain("```");
expect(firstQuestion.text).toContain("`some type` of question");
expect(firstQuestion.answers[0].text).toBe("true");
expect(firstQuestion.answers[0].correct).toBe(true);
expect(firstQuestion.answers[1].correct).toBe(false);
expect(firstQuestion.answers[1].text).toContain("endline");
});
it("can parse multiple questions", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Which events are triggered when the user clicks on an input field?
[*] click
---
Points: 2
\`some type\` of question
*a) true
b) false
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const firstQuestion = quiz.questions[0];
expect(firstQuestion.points).toBe(1);
expect(firstQuestion.questionType).toBe(QuestionType.MULTIPLE_ANSWERS);
const secondQuestion = quiz.questions[1];
expect(secondQuestion.points).toBe(2);
expect(secondQuestion.questionType).toBe(QuestionType.MULTIPLE_CHOICE);
});
it("short answer to markdown is correct", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Which events are triggered when the user clicks on an input field?
short answer
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const firstQuestion = quiz.questions[0];
const questionMarkdown =
quizQuestionMarkdownUtils.toMarkdown(firstQuestion);
const expectedMarkdown = `Points: 1
Which events are triggered when the user clicks on an input field?
short_answer`;
expect(questionMarkdown).toContain(expectedMarkdown);
});
it("negative points is allowed", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Points: -4
Which events are triggered when the user clicks on an input field?
short answer
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const firstQuestion = quiz.questions[0];
expect(firstQuestion.points).toBe(-4);
});
it("floating point points is allowed", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Points: 4.56
Which events are triggered when the user clicks on an input field?
short answer
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const firstQuestion = quiz.questions[0];
expect(firstQuestion.points).toBe(4.56);
});
});

View File

@@ -0,0 +1,137 @@
import { QuestionType } from "../../quiz/localQuizQuestion";
import { quizMarkdownUtils } from "../../quiz/utils/quizMarkdownUtils";
import { quizQuestionMarkdownUtils } from "../../quiz/utils/quizQuestionMarkdownUtils";
import { describe, it, expect } from "vitest";
describe("TextAnswerTests", () => {
it("can parse essay", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Which events are triggered when the user clicks on an input field?
essay
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const firstQuestion = quiz.questions[0];
expect(firstQuestion.points).toBe(1);
expect(firstQuestion.questionType).toBe(QuestionType.ESSAY);
expect(firstQuestion.text).not.toContain("essay");
});
it("can parse short answer", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Which events are triggered when the user clicks on an input field?
short answer
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const firstQuestion = quiz.questions[0];
expect(firstQuestion.points).toBe(1);
expect(firstQuestion.questionType).toBe(QuestionType.SHORT_ANSWER);
expect(firstQuestion.text).not.toContain("short answer");
});
it("short answer to markdown is correct", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Which events are triggered when the user clicks on an input field?
short answer
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const firstQuestion = quiz.questions[0];
const questionMarkdown =
quizQuestionMarkdownUtils.toMarkdown(firstQuestion);
const expectedMarkdown = `Points: 1
Which events are triggered when the user clicks on an input field?
short_answer`;
expect(questionMarkdown).toContain(expectedMarkdown);
});
it("essay question to markdown is correct", () => {
const rawMarkdownQuiz = `
Name: Test Quiz
ShuffleAnswers: true
OneQuestionAtATime: false
DueAt: 08/21/2023 23:59:00
LockAt: 08/21/2023 23:59:00
AssignmentGroup: Assignments
AllowedAttempts: -1
Description: this is the
multi line
description
---
Which events are triggered when the user clicks on an input field?
essay
`;
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
const firstQuestion = quiz.questions[0];
const questionMarkdown =
quizQuestionMarkdownUtils.toMarkdown(firstQuestion);
const expectedMarkdown = `Points: 1
Which events are triggered when the user clicks on an input field?
essay`;
expect(questionMarkdown).toContain(expectedMarkdown);
});
// it("Can parse short answer with auto graded answers", () => {
// const rawMarkdownQuiz = `
// Name: Test Quiz
// ShuffleAnswers: true
// OneQuestionAtATime: false
// DueAt: 08/21/2023 23:59:00
// LockAt: 08/21/2023 23:59:00
// AssignmentGroup: Assignments
// AllowedAttempts: -1
// Description: this is the
// multi line
// description
// ---
// Which events are triggered when the user clicks on an input field?
// *a) test
// short_answer=
// `;
// const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz);
// const firstQuestion = quiz.questions[0];
// expect(firstQuestion.questionType).toBe(QuestionType.SHORT_ANSWER_WITH_ANSWERS)
// });
});

View File

@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import { RubricItem, rubricItemIsExtraCredit } from "../assignment/rubricItem";
import { assignmentMarkdownParser } from "../assignment/utils/assignmentMarkdownParser";
describe("RubricMarkdownTests", () => {
it("can parse one item", () => {
const rawRubric = `
- 2pts: this is the task
`;
const rubric: RubricItem[] =
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
expect(rubric.length).toBe(1);
expect(rubricItemIsExtraCredit(rubric[0])).toBe(false);
expect(rubric[0].label).toBe("this is the task");
expect(rubric[0].points).toBe(2);
});
it("can parse multiple items", () => {
const rawRubric = `
- 2pts: this is the task
- 3pts: this is the other task
`;
const rubric: RubricItem[] =
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
expect(rubric.length).toBe(2);
expect(rubricItemIsExtraCredit(rubric[0])).toBe(false);
expect(rubric[1].label).toBe("this is the other task");
expect(rubric[1].points).toBe(3);
});
it("can parse single point", () => {
const rawRubric = `
- 1pt: this is the task
`;
const rubric: RubricItem[] =
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
expect(rubricItemIsExtraCredit(rubric[0])).toBe(false);
expect(rubric[0].label).toBe("this is the task");
expect(rubric[0].points).toBe(1);
});
it("can parse single extra credit (lower case)", () => {
const rawRubric = `
- 1pt: (extra credit) this is the task
`;
const rubric: RubricItem[] =
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
expect(rubricItemIsExtraCredit(rubric[0])).toBe(true);
expect(rubric[0].label).toBe("(extra credit) this is the task");
});
it("can parse single extra credit (upper case)", () => {
const rawRubric = `
- 1pt: (Extra Credit) this is the task
`;
const rubric: RubricItem[] =
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
expect(rubricItemIsExtraCredit(rubric[0])).toBe(true);
expect(rubric[0].label).toBe("(Extra Credit) this is the task");
});
it("can parse floating point numbers", () => {
const rawRubric = `
- 1.5pt: this is the task
`;
const rubric: RubricItem[] =
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
expect(rubric[0].points).toBe(1.5);
});
it("can parse negative numbers", () => {
const rawRubric = `
- -2pt: this is the task
`;
const rubric: RubricItem[] =
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
expect(rubric[0].points).toBe(-2);
});
it("can parse negative floating point numbers", () => {
const rawRubric = `
- -2895.00053pt: this is the task
`;
const rubric: RubricItem[] =
assignmentMarkdownParser.parseRubricMarkdown(rawRubric);
expect(rubric[0].points).toBe(-2895.00053);
});
});

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from "vitest";
import { parseHolidays } from "../utils/settingsUtils";
describe("can parse holiday string", () => {
it("can parse empty list", () => {
const testString = `
springBreak:
`;
const output = parseHolidays(testString);
expect(output).toEqual([{ name: "springBreak", days: [] }]);
});
it("can parse list with date", () => {
const testString = `
springBreak:
- 10/12/2024
`;
const output = parseHolidays(testString);
expect(output).toEqual([{ name: "springBreak", days: ["10/12/2024"] }]);
});
it("can parse list with two dates", () => {
const testString = `
springBreak:
- 10/12/2024
- 10/13/2024
`;
const output = parseHolidays(testString);
expect(output).toEqual([
{ name: "springBreak", days: ["10/12/2024", "10/13/2024"] },
]);
});
});

View File

@@ -0,0 +1,222 @@
import { describe, it, expect } from "vitest";
import { LocalAssignment } from "../assignment/localAssignment";
import {
prepAssignmentForNewSemester,
prepLectureForNewSemester,
prepPageForNewSemester,
prepQuizForNewSemester,
} from "../utils/semesterTransferUtils";
import { LocalQuiz } from "../quiz/localQuiz";
import { LocalCoursePage } from "../page/localCoursePage";
import { Lecture } from "../lecture";
describe("can take an assignment and template it for a new semester", () => {
it("can sanitize assignment github classroom repo url", () => {
const assignment: LocalAssignment = {
name: "test assignment",
description: `
## test description
[GitHub Classroom Assignment](https://classroom.github.com/a/y_eOxTfL)
other stuff below`,
dueAt: "08/21/2023 23:59:00",
lockAt: "08/21/2023 23:59:00",
submissionTypes: [],
localAssignmentGroupName: "Final Project",
rubric: [],
allowedFileUploadExtensions: [],
};
const oldSemesterStartDate = "08/26/2023 23:59:00";
const newSemesterStartDate = "01/08/2024 23:59:00";
const sanitizedAssignment = prepAssignmentForNewSemester(
assignment,
oldSemesterStartDate,
newSemesterStartDate
);
expect(sanitizedAssignment.description).toEqual(`
## test description
[GitHub Classroom Assignment](insert_github_classroom_url)
other stuff below`);
});
it("can sanitize assignment github classroom repo url 2", () => {
const assignment: LocalAssignment = {
name: "test assignment",
description: `
<https://classroom.github.com/a/y_eOxTfL>
other stuff below`,
dueAt: "08/21/2023 23:59:00",
lockAt: "08/21/2023 23:59:00",
submissionTypes: [],
localAssignmentGroupName: "Final Project",
rubric: [],
allowedFileUploadExtensions: [],
};
const oldSemesterStartDate = "08/26/2023 23:59:00";
const newSemesterStartDate = "01/08/2024 23:59:00";
const sanitizedAssignment = prepAssignmentForNewSemester(
assignment,
oldSemesterStartDate,
newSemesterStartDate
);
expect(sanitizedAssignment.description).toEqual(`
<insert_github_classroom_url>
other stuff below`);
});
it("can sanitize assignment github classroom repo url 3", () => {
const assignment: LocalAssignment = {
name: "test assignment",
description: `https://classroom.github.com/a/y_eOxTfL other things`,
dueAt: "08/21/2023 23:59:00",
lockAt: "08/21/2023 23:59:00",
submissionTypes: [],
localAssignmentGroupName: "Final Project",
rubric: [],
allowedFileUploadExtensions: [],
};
const oldSemesterStartDate = "08/26/2023 23:59:00";
const newSemesterStartDate = "01/08/2024 23:59:00";
const sanitizedAssignment = prepAssignmentForNewSemester(
assignment,
oldSemesterStartDate,
newSemesterStartDate
);
expect(sanitizedAssignment.description).toEqual(
`insert_github_classroom_url other things`
);
});
});
describe("can offset date based on new semester start", () => {
it("assignment with new semester start", () => {
const assignment: LocalAssignment = {
name: "test assignment",
description: `https://classroom.github.com/a/y_eOxTfL other things`,
dueAt: "08/29/2023 23:59:00",
lockAt: "08/29/2023 23:59:00",
submissionTypes: [],
localAssignmentGroupName: "Final Project",
rubric: [],
allowedFileUploadExtensions: [],
};
const oldSemesterStartDate = "08/26/2023 23:59:00";
const newSemesterStartDate = "01/08/2024 23:59:00";
const sanitizedAssignment = prepAssignmentForNewSemester(
assignment,
oldSemesterStartDate,
newSemesterStartDate
);
expect(sanitizedAssignment.dueAt).toEqual("01/11/2024 23:59:00");
expect(sanitizedAssignment.lockAt).toEqual("01/11/2024 23:59:00");
});
it("assignment with new semester start, no lock date", () => {
const assignment: LocalAssignment = {
name: "test assignment",
description: `https://classroom.github.com/a/y_eOxTfL other things`,
dueAt: "08/29/2023 23:59:00",
lockAt: undefined,
submissionTypes: [],
localAssignmentGroupName: "Final Project",
rubric: [],
allowedFileUploadExtensions: [],
};
const oldSemesterStartDate = "08/26/2023 23:59:00";
const newSemesterStartDate = "01/08/2024 23:59:00";
const sanitizedAssignment = prepAssignmentForNewSemester(
assignment,
oldSemesterStartDate,
newSemesterStartDate
);
expect(sanitizedAssignment.dueAt).toEqual("01/11/2024 23:59:00");
expect(sanitizedAssignment.lockAt).toEqual(undefined);
});
});
describe("can prep quizzes", () => {
it("quiz gets new lock and due dates", () => {
const quiz: LocalQuiz = {
name: "Test Quiz",
description: `
# quiz description
`,
dueAt: "08/29/2023 23:59:00",
lockAt: "08/30/2023 23:59:00",
shuffleAnswers: true,
oneQuestionAtATime: false,
localAssignmentGroupName: "someId",
allowedAttempts: -1,
showCorrectAnswers: false,
questions: [],
};
const oldSemesterStartDate = "08/26/2023 23:59:00";
const newSemesterStartDate = "01/08/2024 23:59:00";
const sanitizedQuiz = prepQuizForNewSemester(
quiz,
oldSemesterStartDate,
newSemesterStartDate
);
expect(sanitizedQuiz.dueAt).toEqual("01/11/2024 23:59:00");
expect(sanitizedQuiz.lockAt).toEqual("01/12/2024 23:59:00");
});
});
describe("can prep pages", () => {
it("page gets new due date and github url changes", () => {
const page: LocalCoursePage = {
name: "test title",
text: "test text content",
dueAt: "08/30/2023 23:59:00",
};
const oldSemesterStartDate = "08/26/2023 23:59:00";
const newSemesterStartDate = "01/08/2024 23:59:00";
const sanitizedPage = prepPageForNewSemester(
page,
oldSemesterStartDate,
newSemesterStartDate
);
expect(sanitizedPage.dueAt).toEqual("01/12/2024 23:59:00");
});
});
describe("can prep lecture", () => {
it("lecture gets new date, github url changes", () => {
const lecture: Lecture = {
name: "test title",
date: "08/30/2023",
content: "test text content",
};
const oldSemesterStartDate = "08/26/2023 23:59:00";
const newSemesterStartDate = "01/08/2024 23:59:00";
const sanitizedLecture = prepLectureForNewSemester(
lecture,
oldSemesterStartDate,
newSemesterStartDate
);
expect(sanitizedLecture.date).toEqual("01/12/2024");
});
});

View File

@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import { dateToMarkdownString, getDateFromString } from "../utils/timeUtils";
describe("Can properly handle expected date formats", () => {
it("can use AM/PM dates", () => {
const dateString = "8/27/2024 1:00:00AM";
const dateObject = getDateFromString(dateString);
expect(dateObject).not.toBeUndefined();
});
it("can use 24 hour dates", () => {
const dateString = "8/27/2024 23:95:00";
const dateObject = getDateFromString(dateString);
expect(dateObject).not.toBeUndefined();
});
it("can use ISO format", () => {
const dateString = "2024-08-26T00:00:00.0000000";
const dateObject = getDateFromString(dateString);
expect(dateObject).not.toBeUndefined();
});
it("can use other ISO format", () => {
const dateString = "2024-08-26T06:00:00Z";
const dateObject = getDateFromString(dateString);
expect(dateObject).not.toBeUndefined();
});
it("can get correct time from format", () => {
const dateString = "08/28/2024 23:59:00";
const dateObject = getDateFromString(dateString);
expect(dateObject?.getDate()).toBe(28);
expect(dateObject?.getMonth()).toBe(8 - 1); // 0 based
expect(dateObject?.getFullYear()).toBe(2024);
expect(dateObject?.getMinutes()).toBe(59);
expect(dateObject?.getHours()).toBe(23);
expect(dateObject?.getSeconds()).toBe(0);
});
it("can get correct time from format", () => {
const dateString = "8/27/2024 1:00:00AM";
const dateObject = getDateFromString(dateString);
expect(dateObject?.getDate()).toBe(27);
expect(dateObject?.getMonth()).toBe(8 - 1); // 0 based
expect(dateObject?.getFullYear()).toBe(2024);
expect(dateObject?.getMinutes()).toBe(0);
expect(dateObject?.getHours()).toBe(1);
expect(dateObject?.getSeconds()).toBe(0);
});
it("can get correct time from format", () => {
const dateString = "08/27/2024 23:59:00";
const dateObject = getDateFromString(dateString);
expect(dateObject).not.toBeUndefined()
const updatedString = dateToMarkdownString(dateObject!)
expect(updatedString).toBe(dateString)
});
it("can handle canvas time format", () => {
const dateString = "8/29/2024, 5:00:00 PM";
const dateObject = getDateFromString(dateString);
expect(dateObject).not.toBeUndefined()
const updatedString = dateToMarkdownString(dateObject!)
expect(updatedString).toBe("08/29/2024 17:00:00")
})
it("can handle date without time", () => {
const dateString = "8/29/2024";
const dateObject = getDateFromString(dateString);
expect(dateObject).not.toBeUndefined()
const updatedString = dateToMarkdownString(dateObject!)
expect(updatedString).toBe("08/29/2024 00:00:00")
})
});

View File

@@ -0,0 +1,9 @@
import { Lecture } from "../lecture";
import { getDateOnlyMarkdownString } from "./timeUtils";
export function getLectureForDay(weeks: { weekName: string; lectures: Lecture[]; }[], dayAsDate: Date) {
return weeks
.flatMap((w) => w.lectures)
.find((l) => l.date == getDateOnlyMarkdownString(dayAsDate));
}

View File

@@ -0,0 +1,126 @@
import { LocalAssignment } from "../assignment/localAssignment";
import { Lecture } from "../lecture";
import { LocalCoursePage } from "../page/localCoursePage";
import { LocalQuiz } from "../quiz/localQuiz";
import {
dateToMarkdownString,
getDateFromStringOrThrow,
} from "./timeUtils";
export const prepAssignmentForNewSemester = (
assignment: LocalAssignment,
oldSemesterStartDate: string,
newSemesterStartDate: string
): LocalAssignment => {
const descriptionWithoutGithubClassroom = replaceClassroomUrl(
assignment.description
);
return {
...assignment,
description: descriptionWithoutGithubClassroom,
dueAt:
newDateOffset(
assignment.dueAt,
oldSemesterStartDate,
newSemesterStartDate
) ?? assignment.dueAt,
lockAt: newDateOffset(
assignment.lockAt,
oldSemesterStartDate,
newSemesterStartDate
),
};
};
export const prepQuizForNewSemester = (
quiz: LocalQuiz,
oldSemesterStartDate: string,
newSemesterStartDate: string
): LocalQuiz => {
const descriptionWithoutGithubClassroom = replaceClassroomUrl(
quiz.description
);
return {
...quiz,
description: descriptionWithoutGithubClassroom,
dueAt:
newDateOffset(quiz.dueAt, oldSemesterStartDate, newSemesterStartDate) ??
quiz.dueAt,
lockAt: newDateOffset(
quiz.lockAt,
oldSemesterStartDate,
newSemesterStartDate
),
};
};
export const prepPageForNewSemester = (
page: LocalCoursePage,
oldSemesterStartDate: string,
newSemesterStartDate: string
): LocalCoursePage => {
const updatedText = replaceClassroomUrl(page.text);
return {
...page,
text: updatedText,
dueAt:
newDateOffset(page.dueAt, oldSemesterStartDate, newSemesterStartDate) ??
page.dueAt,
};
};
export const prepLectureForNewSemester = (
lecture: Lecture,
oldSemesterStartDate: string,
newSemesterStartDate: string
): Lecture => {
const updatedText = replaceClassroomUrl(lecture.content);
const newDate = newDateOffset(
lecture.date,
oldSemesterStartDate,
newSemesterStartDate
);
const newDateOnly = newDate?.split(" ")[0];
return {
...lecture,
content: updatedText,
date: newDateOnly ?? lecture.date,
};
};
const replaceClassroomUrl = (value: string) => {
const classroomPattern =
/https:\/\/classroom\.github\.com\/[a-zA-Z0-9\/._-]+/g;
const withoutGithubClassroom = value.replace(
classroomPattern,
"insert_github_classroom_url"
);
return withoutGithubClassroom;
};
const newDateOffset = (
dateString: string | undefined,
oldSemesterStartDate: string,
newSemesterStartDate: string
) => {
if (!dateString) return dateString;
const oldStart = getDateFromStringOrThrow(
oldSemesterStartDate,
"semester start date in new semester offset"
);
const newStart = getDateFromStringOrThrow(
newSemesterStartDate,
"new semester start date in new semester offset"
);
const date = getDateFromStringOrThrow(
dateString,
"date in new semester offset"
);
const offset = date.getTime() - oldStart.getTime();
const newUnixTime = offset + newStart.getTime();
const newDate = new Date(newUnixTime);
return dateToMarkdownString(newDate);
};

View File

@@ -0,0 +1,50 @@
import {
getDateFromStringOrThrow,
getDateOnlyMarkdownString,
} from "./timeUtils";
export const parseHolidays = (
inputText: string
): {
name: string;
days: string[];
}[] => {
let holidays: {
name: string;
days: string[];
}[] = [];
const lines = inputText.split("\n").filter((line) => line.trim() !== "");
let currentHoliday: string | null = null;
lines.forEach((line) => {
if (line.includes(":")) {
const holidayName = line.split(":")[0].trim();
currentHoliday = holidayName;
holidays = [...holidays, { name: holidayName, days: [] }];
} else if (currentHoliday && line.startsWith("-")) {
const date = line.replace("-", "").trim();
const dateObject = getDateFromStringOrThrow(date, "parsing holiday text");
const holiday = holidays.find((h) => h.name == currentHoliday);
holiday?.days.push(getDateOnlyMarkdownString(dateObject));
}
});
return holidays;
};
export const holidaysToString = (
holidays: {
name: string;
days: string[];
}[]
) => {
const entries = holidays.map((holiday) => {
const title = holiday.name + ":\n";
const days = holiday.days.map((d) => `- ${d}\n`);
return title + days.join("");
});
return entries.join("");
};

View File

@@ -0,0 +1,106 @@
const _getDateFromAMPM = (
datePart: string,
timePart: string,
amPmPart: string
): Date | undefined => {
const [month, day, year] = datePart.split("/").map(Number);
const [hours, minutes, seconds] = timePart.split(":").map(Number);
let adjustedHours = hours;
if (amPmPart) {
const upperMeridian = amPmPart.toUpperCase();
if (upperMeridian === "PM" && hours < 12) {
adjustedHours += 12;
} else if (upperMeridian === "AM" && hours === 12) {
adjustedHours = 0;
}
}
const date = new Date(year, month - 1, day, adjustedHours, minutes, seconds);
return isNaN(date.getTime()) ? undefined : date;
};
const _getDateFromMilitary = (
datePart: string,
timePart: string
): Date | undefined => {
const [month, day, year] = datePart.split("/").map(Number);
const [hours, minutes, seconds] = timePart.split(":").map(Number);
const date = new Date(year, month - 1, day, hours, minutes, seconds);
return isNaN(date.getTime()) ? undefined : date;
};
const _getDateFromISO = (value: string): Date | undefined => {
const date = new Date(value);
return isNaN(date.getTime()) ? undefined : date;
};
const _getDateFromDateOnly = (datePart: string): Date | undefined => {
const [month, day, year] = datePart.split("/").map(Number);
const date = new Date(year, month - 1, day);
return isNaN(date.getTime()) ? undefined : date;
};
export const getDateFromString = (value: string): Date | undefined => {
const ampmDateRegex =
/^\d{1,2}\/\d{1,2}\/\d{4},? \d{1,2}:\d{2}:\d{2}\s{1}[APap][Mm]$/; //"M/D/YYYY h:mm:ss AM/PM" or "M/D/YYYY, h:mm:ss AM/PM"
const militaryDateRegex = /^\d{1,2}\/\d{1,2}\/\d{4} \d{1,2}:\d{2}:\d{2}$/; //"MM/DD/YYYY HH:mm:ss"
const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}((.\d+)|(Z))$/; //"2024-08-26T00:00:00.0000000"
const dateOnlyRegex = /^\d{1,2}\/\d{1,2}\/\d{4}$/; // "M/D/YYYY" or "MM/DD/YYYY"
if (isoDateRegex.test(value)) {
return _getDateFromISO(value);
} else if (ampmDateRegex.test(value)) {
const [datePart, timePart, amPmPart] = value.split(/,?[\s\u202F]+/);
return _getDateFromAMPM(datePart, timePart, amPmPart);
} else if (militaryDateRegex.test(value)) {
const [datePart, timePart] = value.split(" ");
return _getDateFromMilitary(datePart, timePart);
} if (dateOnlyRegex.test(value)) {
return _getDateFromDateOnly(value);
} else {
if (value) console.log("invalid date format", value);
return undefined;
}
};
export const getDateFromStringOrThrow = (
value: string,
labelForError: string
): Date => {
const d = getDateFromString(value);
if (!d) throw Error(`Invalid date format for ${labelForError}, ${value}`);
return d;
};
export const verifyDateStringOrUndefined = (
value: string
): string | undefined => {
const date = getDateFromString(value);
return date ? dateToMarkdownString(date) : undefined;
};
export const verifyDateOrThrow = (
value: string,
labelForError: string
): string => {
const myDate = getDateFromString(value);
if (!myDate) throw new Error(`Invalid format for ${labelForError}: ${value}`);
return dateToMarkdownString(myDate);
};
export const dateToMarkdownString = (date: Date) => {
const stringDay = String(date.getDate()).padStart(2, "0");
const stringMonth = String(date.getMonth() + 1).padStart(2, "0"); // Months are zero-based
const stringYear = date.getFullYear();
const stringHours = String(date.getHours()).padStart(2, "0");
const stringMinutes = String(date.getMinutes()).padStart(2, "0");
const stringSeconds = String(date.getSeconds()).padStart(2, "0");
return `${stringMonth}/${stringDay}/${stringYear} ${stringHours}:${stringMinutes}:${stringSeconds}`;
};
export const getDateOnlyMarkdownString = (date: Date) => {
return dateToMarkdownString(date).split(" ")[0];
};