mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-25 23:28:33 -06:00
Compare commits
2 Commits
9ce42c21f9
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a350f5629 | ||
|
|
c29c7c0853 |
8
.github/workflows/docker-deploy.yml
vendored
8
.github/workflows/docker-deploy.yml
vendored
@@ -2,7 +2,7 @@ name: Deploy to Docker Hub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ main, development, staging ]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -21,7 +21,11 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract branch name
|
||||
shell: bash
|
||||
run: echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
chmod +x ./build.sh
|
||||
./build.sh -t -p
|
||||
./build.sh -t -p -b "$BRANCH_NAME"
|
||||
101
build.sh
101
build.sh
@@ -3,11 +3,12 @@
|
||||
MAJOR_VERSION="3"
|
||||
MINOR_VERSION="0"
|
||||
VERSION="$MAJOR_VERSION.$MINOR_VERSION"
|
||||
BRANCH=""
|
||||
|
||||
TAG_FLAG=false
|
||||
PUSH_FLAG=false
|
||||
|
||||
while getopts ":tp" opt; do
|
||||
while getopts ":tpb:" opt; do
|
||||
case ${opt} in
|
||||
t)
|
||||
TAG_FLAG=true
|
||||
@@ -15,9 +16,12 @@ while getopts ":tp" opt; do
|
||||
p)
|
||||
PUSH_FLAG=true
|
||||
;;
|
||||
b)
|
||||
BRANCH="$OPTARG"
|
||||
;;
|
||||
\?)
|
||||
echo "Invalid option: -$OPTARG" >&2
|
||||
echo "Usage: $0 [-t] [-p]"
|
||||
echo "Usage: $0 [-t] [-p] [-b branch]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -29,24 +33,72 @@ docker build -t canvas_management:$VERSION .
|
||||
|
||||
if [ "$TAG_FLAG" = true ]; then
|
||||
echo "Tagging images..."
|
||||
|
||||
if [ -n "$BRANCH" ]; then
|
||||
# Branch-specific tags
|
||||
echo "alexmickelson/canvas_management:$VERSION-$BRANCH"
|
||||
echo "alexmickelson/canvas_management:$MAJOR_VERSION-$BRANCH"
|
||||
echo "alexmickelson/canvas_management:latest-$BRANCH"
|
||||
|
||||
docker image tag canvas_management:"$VERSION" alexmickelson/canvas_management:"$VERSION-$BRANCH"
|
||||
docker image tag canvas_management:"$VERSION" alexmickelson/canvas_management:"$MAJOR_VERSION-$BRANCH"
|
||||
docker image tag canvas_management:"$VERSION" alexmickelson/canvas_management:latest-$BRANCH
|
||||
|
||||
# Only create non-branch tags if branch is "main"
|
||||
if [ "$BRANCH" = "main" ]; then
|
||||
echo "alexmickelson/canvas_management:$VERSION"
|
||||
echo "alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||
echo "alexmickelson/canvas_management:latest"
|
||||
|
||||
docker image tag canvas_management:"$VERSION" alexmickelson/canvas_management:"$VERSION"
|
||||
docker image tag canvas_management:"$VERSION" alexmickelson/canvas_management:"$MAJOR_VERSION"
|
||||
docker image tag canvas_management:latest alexmickelson/canvas_management:latest
|
||||
fi
|
||||
|
||||
if [ "$PUSH_FLAG" = true ]; then
|
||||
echo "Pushing images..."
|
||||
docker image tag canvas_management:"$VERSION" alexmickelson/canvas_management:latest
|
||||
fi
|
||||
else
|
||||
# No branch specified - create standard tags (for local development)
|
||||
echo "alexmickelson/canvas_management:$VERSION"
|
||||
echo "alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||
echo "alexmickelson/canvas_management:latest"
|
||||
|
||||
docker push -q alexmickelson/canvas_management:"$VERSION"
|
||||
docker push -q alexmickelson/canvas_management:"$MAJOR_VERSION"
|
||||
docker push -q alexmickelson/canvas_management:latest
|
||||
docker image tag canvas_management:"$VERSION" alexmickelson/canvas_management:"$VERSION"
|
||||
docker image tag canvas_management:"$VERSION" alexmickelson/canvas_management:"$MAJOR_VERSION"
|
||||
docker image tag canvas_management:"$VERSION" alexmickelson/canvas_management:latest
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$PUSH_FLAG" = true ]; then
|
||||
echo "Pushing images..."
|
||||
|
||||
if [ -n "$BRANCH" ]; then
|
||||
# Push branch-specific tags
|
||||
echo "alexmickelson/canvas_management:$VERSION-$BRANCH"
|
||||
echo "alexmickelson/canvas_management:$MAJOR_VERSION-$BRANCH"
|
||||
echo "alexmickelson/canvas_management:latest-$BRANCH"
|
||||
|
||||
docker push alexmickelson/canvas_management:"$VERSION-$BRANCH"
|
||||
docker push alexmickelson/canvas_management:"$MAJOR_VERSION-$BRANCH"
|
||||
docker push alexmickelson/canvas_management:latest-$BRANCH
|
||||
|
||||
# Only push non-branch tags if branch is "main"
|
||||
if [ "$BRANCH" = "main" ]; then
|
||||
echo "alexmickelson/canvas_management:$VERSION"
|
||||
echo "alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||
echo "alexmickelson/canvas_management:latest"
|
||||
|
||||
docker push alexmickelson/canvas_management:"$VERSION"
|
||||
docker push alexmickelson/canvas_management:"$MAJOR_VERSION"
|
||||
docker push alexmickelson/canvas_management:latest
|
||||
fi
|
||||
else
|
||||
# No branch specified - push standard tags (for local development)
|
||||
echo "alexmickelson/canvas_management:$VERSION"
|
||||
echo "alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||
echo "alexmickelson/canvas_management:latest"
|
||||
|
||||
docker push alexmickelson/canvas_management:"$VERSION"
|
||||
docker push alexmickelson/canvas_management:"$MAJOR_VERSION"
|
||||
docker push alexmickelson/canvas_management:latest
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$TAG_FLAG" = false ] && [ "$PUSH_FLAG" = false ]; then
|
||||
@@ -54,12 +106,33 @@ if [ "$TAG_FLAG" = false ] && [ "$PUSH_FLAG" = false ]; then
|
||||
echo "Build complete."
|
||||
echo "To tag, run with -t flag."
|
||||
echo "To push, run with -p flag."
|
||||
echo "To build for a specific branch, use -b branch_name flag."
|
||||
echo "Or manually run:"
|
||||
echo ""
|
||||
if [ -n "$BRANCH" ]; then
|
||||
echo "# Branch-specific tags:"
|
||||
echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:$VERSION-$BRANCH"
|
||||
echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:$MAJOR_VERSION-$BRANCH"
|
||||
echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:latest-$BRANCH"
|
||||
echo "docker push alexmickelson/canvas_management:$VERSION-$BRANCH"
|
||||
echo "docker push alexmickelson/canvas_management:$MAJOR_VERSION-$BRANCH"
|
||||
echo "docker push alexmickelson/canvas_management:latest-$BRANCH"
|
||||
if [ "$BRANCH" = "main" ]; then
|
||||
echo ""
|
||||
echo "# Main branch also gets standard tags:"
|
||||
echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:$VERSION"
|
||||
echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||
echo "docker image tag canvas_management:latest alexmickelson/canvas_management:latest"
|
||||
echo "docker push -q alexmickelson/canvas_management:$VERSION"
|
||||
echo "docker push -q alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||
echo "docker push -q alexmickelson/canvas_management:latest"
|
||||
echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:latest"
|
||||
echo "docker push alexmickelson/canvas_management:$VERSION"
|
||||
echo "docker push alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||
echo "docker push alexmickelson/canvas_management:latest"
|
||||
fi
|
||||
else
|
||||
echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:$VERSION"
|
||||
echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||
echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:latest"
|
||||
echo "docker push alexmickelson/canvas_management:$VERSION"
|
||||
echo "docker push alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||
echo "docker push alexmickelson/canvas_management:latest"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -19,15 +19,15 @@ services:
|
||||
- ~/projects/facultyFiles:/app/public/images/facultyFiles
|
||||
|
||||
|
||||
# redis:
|
||||
# image: redis
|
||||
# container_name: redis
|
||||
# volumes:
|
||||
# - redis-data:/data
|
||||
# restart: unless-stopped
|
||||
redis:
|
||||
image: redis
|
||||
container_name: redis
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
# volumes:
|
||||
# redis-data:
|
||||
volumes:
|
||||
redis-data:
|
||||
|
||||
# https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
|
||||
# https://github.com/jonas-merkle/container-cloudflare-tunnel
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
courses:
|
||||
- path: ./4850_AdvancedFE/2025-fall-alex/modules/
|
||||
name: Adv Frontend
|
||||
- path: ./1420/2025-fall-alex/modules/
|
||||
name: "1420"
|
||||
- path: ./1810/2025-fall-alex/modules/
|
||||
name: Web Intro
|
||||
- path: ./1430/2025-fall-alex/modules/
|
||||
name: UX
|
||||
- path: ./1425/2025-fall-alex/modules/
|
||||
name: "1425"
|
||||
- path: ./4850_AdvancedFE/2026-spring-alex/modules
|
||||
name: Adv Frontend
|
||||
- path: ./1400/2026_spring_alex/modules
|
||||
name: "1400"
|
||||
- path: ./1405/2026_spring_alex
|
||||
name: "1405"
|
||||
- path: ./3840_Telemetry/2026_spring_alex
|
||||
name: Telem and Ops
|
||||
- path: ./4620_Distributed/2026-spring-alex/modules
|
||||
name: Distributed
|
||||
- path: ./4620_Distributed/2025Spring/modules/
|
||||
name: distributed-old
|
||||
- path: ./1405/2025_spring_alex/
|
||||
name: 1405_old
|
||||
- path: ./3840_Telemetry/2025_spring_alex/modules/
|
||||
name: telemetry-old
|
||||
- path: ./4850_AdvancedFE/2025-fall-alex/modules/
|
||||
name: adv-frontend-old
|
||||
- path: ./1810/2026-spring-alex/modules/
|
||||
name: Web Intro
|
||||
name: Telem and Ops
|
||||
- path: ./4850_AdvancedFE/2024-fall-alex/modules/
|
||||
name: Old Adv Frontend
|
||||
- path: ./1430/2025-spring-jonathan/Modules/
|
||||
name: Jonathan UX
|
||||
- path: ./1400/2025_spring_alex/modules/
|
||||
name: 1400-spring
|
||||
- path: ./1420/2024-fall/Modules/
|
||||
name: 1420_old
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
"marked-katex-extension": "^5.1.5",
|
||||
"mcp-handler": "^1.0.0",
|
||||
"next": "^15.3.5",
|
||||
"pako": "^2.1.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"socket.io": "^4.8.1",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -65,9 +65,6 @@ importers:
|
||||
next:
|
||||
specifier: ^15.3.5
|
||||
version: 15.3.5(@babel/core@7.28.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
pako:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0
|
||||
@@ -2631,9 +2628,6 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
pako@2.1.0:
|
||||
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -5859,8 +5853,6 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
pako@2.1.0: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
||||
@@ -30,11 +30,6 @@ Content-Type: application/json
|
||||
GET https://snow.instructure.com/api/v1/courses/958185/assignments
|
||||
Authorization: Bearer {{$dotenv CANVAS_TOKEN}}
|
||||
|
||||
|
||||
###
|
||||
GET https://snow.instructure.com/api/v1/courses/1155293/quizzes/4366122/questions
|
||||
Authorization: Bearer {{$dotenv CANVAS_TOKEN}}
|
||||
|
||||
###
|
||||
POST https://snow.instructure.com/api/v1/courses/958185/quizzes/3358912/questions
|
||||
Authorization: Bearer {{$dotenv CANVAS_TOKEN}}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
"use client";
|
||||
import { Toggle } from "@/components/form/Toggle";
|
||||
import { useLocalCoursesSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import {
|
||||
useGlobalSettingsQuery,
|
||||
useUpdateGlobalSettingsMutation,
|
||||
} from "@/features/local/globalSettings/globalSettingsHooks";
|
||||
import {
|
||||
getDateKey,
|
||||
getTermName,
|
||||
@@ -12,25 +7,17 @@ import {
|
||||
} from "@/features/local/utils/timeUtils";
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import Modal, { useModal } from "@/components/Modal";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
|
||||
export default function CourseList() {
|
||||
const { data: allSettings } = useLocalCoursesSettingsQuery();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const coursesByStartDate = groupByStartDate(allSettings);
|
||||
|
||||
const sortedDates = Object.keys(coursesByStartDate).sort();
|
||||
|
||||
console.log(allSettings, coursesByStartDate);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Toggle
|
||||
label={"Delete Mode"}
|
||||
value={isDeleting}
|
||||
onChange={(set) => setIsDeleting(set)}
|
||||
/>
|
||||
<div className="flex flex-row ">
|
||||
{sortedDates.map((startDate) => (
|
||||
<div
|
||||
@@ -39,84 +26,9 @@ export default function CourseList() {
|
||||
>
|
||||
<div className="text-center">{getTermName(startDate)}</div>
|
||||
{coursesByStartDate[getDateKey(startDate)].map((settings) => (
|
||||
<CourseItem
|
||||
key={settings.name}
|
||||
courseName={settings.name}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CourseItem({
|
||||
courseName,
|
||||
isDeleting,
|
||||
}: {
|
||||
courseName: string;
|
||||
isDeleting: boolean;
|
||||
}) {
|
||||
const { data: globalSettings } = useGlobalSettingsQuery();
|
||||
const updateSettingsMutation = useUpdateGlobalSettingsMutation();
|
||||
const modal = useModal();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
{isDeleting && (
|
||||
<Modal
|
||||
modalControl={modal}
|
||||
buttonText="X"
|
||||
buttonClass="
|
||||
unstyled
|
||||
text-red-200 hover:text-red-400
|
||||
bg-red-950/50 hover:bg-red-950/70
|
||||
transition-all hover:scale-110
|
||||
mb-3
|
||||
"
|
||||
modalWidth="w-1/3"
|
||||
>
|
||||
{({ closeModal }) => (
|
||||
<div>
|
||||
<div className="text-center">
|
||||
Are you sure you want to remove {courseName} from global
|
||||
settings?
|
||||
</div>
|
||||
<br />
|
||||
<div className="flex justify-around gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
await updateSettingsMutation.mutateAsync({
|
||||
globalSettings: {
|
||||
...globalSettings,
|
||||
courses: globalSettings.courses.filter(
|
||||
(course) => course.name !== courseName
|
||||
),
|
||||
},
|
||||
});
|
||||
closeModal();
|
||||
}}
|
||||
disabled={updateSettingsMutation.isPending}
|
||||
className="btn-danger"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
disabled={updateSettingsMutation.isPending}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
{updateSettingsMutation.isPending && <Spinner />}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
<div key={settings.name}>
|
||||
<Link
|
||||
href={getCourseUrl(courseName)}
|
||||
href={getCourseUrl(settings.name)}
|
||||
shallow={true}
|
||||
prefetch={true}
|
||||
className="
|
||||
@@ -125,8 +37,12 @@ function CourseItem({
|
||||
mb-3
|
||||
"
|
||||
>
|
||||
{courseName}
|
||||
{settings.name}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,9 +22,7 @@ import {
|
||||
} from "@/features/local/course/localCourseSettings";
|
||||
import { useCourseListInTermQuery } from "@/features/canvas/hooks/canvasCourseHooks";
|
||||
import { useCanvasTermsQuery } from "@/features/canvas/hooks/canvasHooks";
|
||||
import { useDirectoryExistsQuery } from "@/features/local/utils/storageDirectoryHooks";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const sampleCompose = `services:
|
||||
canvas_manager:
|
||||
image: alexmickelson/canvas_management:2 # pull this image regularly
|
||||
@@ -96,16 +94,11 @@ export default function AddNewCourseToGlobalSettingsForm() {
|
||||
disabled={!formIsComplete || createCourse.isPending}
|
||||
onClick={async () => {
|
||||
if (formIsComplete) {
|
||||
console.log(
|
||||
"Creating course with settings:",
|
||||
selectedDirectory,
|
||||
"old course",
|
||||
courseToImport
|
||||
);
|
||||
console.log("Creating course with settings:", selectedDirectory);
|
||||
const newSettings: LocalCourseSettings = courseToImport
|
||||
? {
|
||||
...courseToImport,
|
||||
name: name,
|
||||
name: selectedDirectory,
|
||||
daysOfWeek: selectedDaysOfWeek,
|
||||
canvasId: selectedCanvasCourse.id,
|
||||
startDate: selectedTerm.start_at ?? "",
|
||||
@@ -121,7 +114,7 @@ export default function AddNewCourseToGlobalSettingsForm() {
|
||||
assets: [],
|
||||
}
|
||||
: {
|
||||
name: name,
|
||||
name: selectedDirectory,
|
||||
assignmentGroups: [],
|
||||
daysOfWeek: selectedDaysOfWeek,
|
||||
canvasId: selectedCanvasCourse.id,
|
||||
@@ -151,6 +144,11 @@ export default function AddNewCourseToGlobalSettingsForm() {
|
||||
</button>
|
||||
</div>
|
||||
{createCourse.isPending && <Spinner />}
|
||||
|
||||
<pre>
|
||||
<div>Example docker compose</div>
|
||||
<code className="language-yml">{sampleCompose}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -182,12 +180,12 @@ function OtherSettings({
|
||||
name: string;
|
||||
setName: Dispatch<SetStateAction<string>>;
|
||||
}) {
|
||||
const { data: canvasCourses, isLoading: canvasCoursesLoading } =
|
||||
useCourseListInTermQuery(selectedTerm.id);
|
||||
const { data: canvasCourses } = useCourseListInTermQuery(selectedTerm.id);
|
||||
const { data: allSettings } = useLocalCoursesSettingsQuery();
|
||||
const [directory, setDirectory] = useState("./");
|
||||
const { data: directoryExists, isLoading: directoryExistsLoading } =
|
||||
useDirectoryExistsQuery(directory);
|
||||
// const directoryIsCourseQuery = useDirectoryIsCourseQuery(
|
||||
// selectedDirectory ?? "./"
|
||||
// );
|
||||
|
||||
const populatedCanvasCourseIds = allSettings?.map((s) => s.canvasId) ?? [];
|
||||
const availableCourses =
|
||||
@@ -206,20 +204,6 @@ function OtherSettings({
|
||||
getOptionName={(c) => c?.name ?? ""}
|
||||
center={true}
|
||||
/>
|
||||
{canvasCoursesLoading && <Spinner />}
|
||||
{!canvasCoursesLoading && availableCourses.length === 0 && (
|
||||
<div className="text-center text-red-300">
|
||||
<div className="flex justify-center ">
|
||||
<div className="text-left">
|
||||
No available courses in this term to add. Either
|
||||
<ol>
|
||||
<li>all courses have already been added, or</li>
|
||||
<li>there are no courses in this term</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StoragePathSelector
|
||||
value={directory}
|
||||
@@ -227,15 +211,6 @@ function OtherSettings({
|
||||
setLastTypedValue={setSelectedDirectory}
|
||||
label={"Storage Folder"}
|
||||
/>
|
||||
<div className="text-center mt-2 min-h-6">
|
||||
{directoryExistsLoading && <Spinner />}
|
||||
{!directoryExistsLoading && directoryExists && (
|
||||
<div className="text-red-300">Directory must be a new folder</div>
|
||||
)}
|
||||
{!directoryExistsLoading && directoryExists === false && (
|
||||
<div className="text-green-300">✓ New folder</div>
|
||||
)}
|
||||
</div>
|
||||
<br />
|
||||
<div className="flex justify-center">
|
||||
<DayOfWeekInput
|
||||
|
||||
@@ -16,7 +16,7 @@ export const AddExistingCourseToGlobalSettings = () => {
|
||||
<div>
|
||||
<div className="flex justify-center">
|
||||
<button className="" onClick={() => setShowForm((i) => !i)}>
|
||||
{showForm ? "Hide Form" : "Import Existing Course"}
|
||||
Add Existing Course
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ export default function AddCourseToGlobalSettings() {
|
||||
<div>
|
||||
<div className="flex justify-center">
|
||||
<button className="" onClick={() => setShowForm((i) => !i)}>
|
||||
{showForm ? "Hide Form" : "Add New Course"}
|
||||
|
||||
Add New Course
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,15 +10,12 @@ const collapseThreshold = 1400;
|
||||
|
||||
export default function CollapsableSidebar() {
|
||||
const [windowCollapseRecommended, setWindowCollapseRecommended] =
|
||||
useState(false);
|
||||
useState(window.innerWidth <= collapseThreshold);
|
||||
const [userCollapsed, setUserCollapsed] = useState<
|
||||
"unset" | "collapsed" | "uncollapsed"
|
||||
>("unset");
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize on mount
|
||||
setWindowCollapseRecommended(window.innerWidth <= collapseThreshold);
|
||||
|
||||
function handleResize() {
|
||||
if (window.innerWidth <= collapseThreshold) {
|
||||
setWindowCollapseRecommended(true);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import {
|
||||
useCanvasAssignmentsQuery,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
} from "@/features/canvas/hooks/canvasQuizHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
|
||||
export function CourseNavigation() {
|
||||
const { data: settings } = useLocalCourseSettingsQuery();
|
||||
@@ -33,8 +33,9 @@ export function CourseNavigation() {
|
||||
|
||||
return (
|
||||
<div className="pb-1 flex flex-row gap-3">
|
||||
<BreadCrumbs />
|
||||
|
||||
<Link href={"/"} className="btn" shallow={true}>
|
||||
Back to Course List
|
||||
</Link>
|
||||
<a
|
||||
href={`https://snow.instructure.com/courses/${settings.canvasId}`}
|
||||
className="btn"
|
||||
|
||||
@@ -47,7 +47,7 @@ export const CalendarMonth = ({ month }: { month: CalendarMonthModel }) => {
|
||||
className={
|
||||
"text-2xl transition-all duration-500 " +
|
||||
"hover:text-slate-50 underline hover:scale-105 " +
|
||||
"flex cursor-pointer"
|
||||
"flex "
|
||||
}
|
||||
onClick={() => setIsExpanded((e) => !e)}
|
||||
role="button"
|
||||
|
||||
@@ -13,11 +13,10 @@ export default function CourseCalendar() {
|
||||
() => getDateFromStringOrThrow(settings.startDate, "course start date"),
|
||||
[settings.startDate]
|
||||
);
|
||||
const endDateTime = useMemo(() => {
|
||||
const date = getDateFromStringOrThrow(settings.endDate, "course end date");
|
||||
date.setDate(date.getDate() + 14); // buffer to make sure calendar shows week of finals and grades due
|
||||
return date;
|
||||
}, [settings.endDate]);
|
||||
const endDateTime = useMemo(
|
||||
() => getDateFromStringOrThrow(settings.endDate, "course end date"),
|
||||
[settings.endDate]
|
||||
);
|
||||
const months = useMemo(
|
||||
() => getMonthsBetweenDates(startDateTime, endDateTime),
|
||||
[endDateTime, startDateTime]
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getLectureForDay } from "@/features/local/utils/lectureUtils";
|
||||
import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
|
||||
import ClientOnly from "@/components/ClientOnly";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { useTooltip } from "@/components/useTooltip";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
|
||||
const { courseName } = useCourseContext();
|
||||
@@ -17,7 +17,8 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
|
||||
const { setIsDragging } = useDragStyleContext();
|
||||
const todaysLecture = getLectureForDay(weeks, dayAsDate);
|
||||
const modal = useModal();
|
||||
const { visible, targetRef, showTooltip, hideTooltip } = useTooltip();
|
||||
const linkRef = useRef<HTMLAnchorElement>(null);
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
||||
|
||||
const lectureName = todaysLecture && (todaysLecture.name || "lecture");
|
||||
|
||||
@@ -43,9 +44,9 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}}
|
||||
ref={targetRef}
|
||||
onMouseEnter={showTooltip}
|
||||
onMouseLeave={hideTooltip}
|
||||
ref={linkRef}
|
||||
onMouseEnter={() => setTooltipVisible(true)}
|
||||
onMouseLeave={() => setTooltipVisible(false)}
|
||||
>
|
||||
{dayAsDate.getDate()} {lectureName}
|
||||
</Link>
|
||||
@@ -64,40 +65,15 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
targetRef={targetRef}
|
||||
visible={visible}
|
||||
targetRef={linkRef}
|
||||
visible={tooltipVisible}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
<Modal
|
||||
buttonComponent={({ openModal }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={22}
|
||||
height={22}
|
||||
className="cursor-pointer hover:scale-125 hover:stroke-slate-400 stroke-slate-500 transition-all m-0.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
onClick={openModal}
|
||||
>
|
||||
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
||||
<g
|
||||
id="SVGRepo_tracerCarrier"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></g>
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<path
|
||||
d="M6 12H18M12 6V18"
|
||||
className=" "
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
modalControl={modal}
|
||||
buttonText="+"
|
||||
buttonClass="unstyled hover:font-bold hover:scale-125 px-1 mb-auto mt-0 pt-0"
|
||||
modalWidth="w-135"
|
||||
>
|
||||
{({ closeModal }) => (
|
||||
|
||||
@@ -1,48 +1,13 @@
|
||||
import { IModuleItem } from "@/features/local/modules/IModuleItem";
|
||||
import { getModuleItemUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useRef, useState } from "react";
|
||||
import { useCourseContext } from "../../context/courseContext";
|
||||
import { useTooltip } from "@/components/useTooltip";
|
||||
import MarkdownDisplay from "@/components/MarkdownDisplay";
|
||||
import { DraggableItem } from "../../context/drag/draggingContext";
|
||||
import ClientOnly from "@/components/ClientOnly";
|
||||
import { useDragStyleContext } from "../../context/drag/dragStyleContext";
|
||||
import { Tooltip } from "../../../../../components/Tooltip";
|
||||
|
||||
function getPreviewContent(
|
||||
type: "assignment" | "page" | "quiz",
|
||||
item: IModuleItem
|
||||
): ReactNode {
|
||||
if (type === "assignment" && "description" in item) {
|
||||
const assignment = item as {
|
||||
description: string;
|
||||
githubClassroomAssignmentShareLink?: string;
|
||||
};
|
||||
return (
|
||||
<MarkdownDisplay
|
||||
markdown={assignment.description}
|
||||
replaceText={[
|
||||
{
|
||||
source: "insert_github_classroom_url",
|
||||
destination: assignment.githubClassroomAssignmentShareLink || "",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
} else if (type === "page" && "text" in item) {
|
||||
return <MarkdownDisplay markdown={item.text as string} />;
|
||||
} else if (type === "quiz" && "questions" in item) {
|
||||
const quiz = item as { questions: { text: string }[] };
|
||||
return quiz.questions.map((q, i: number) => (
|
||||
<div key={i} className="">
|
||||
<MarkdownDisplay markdown={q.text as string} />
|
||||
</div>
|
||||
));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ItemInDay({
|
||||
type,
|
||||
moduleName,
|
||||
@@ -58,8 +23,8 @@ export function ItemInDay({
|
||||
}) {
|
||||
const { courseName } = useCourseContext();
|
||||
const { setIsDragging } = useDragStyleContext();
|
||||
const { visible, targetRef, showTooltip, hideTooltip } = useTooltip(500);
|
||||
|
||||
const linkRef = useRef<HTMLAnchorElement>(null);
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
||||
return (
|
||||
<div className={" relative group "}>
|
||||
<Link
|
||||
@@ -87,26 +52,18 @@ export function ItemInDay({
|
||||
);
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onMouseEnter={showTooltip}
|
||||
onMouseLeave={hideTooltip}
|
||||
ref={targetRef}
|
||||
onMouseEnter={() => setTooltipVisible(true)}
|
||||
onMouseLeave={() => setTooltipVisible(false)}
|
||||
ref={linkRef}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
<ClientOnly>
|
||||
{status === "published" ? (
|
||||
getPreviewContent(type, item) && (
|
||||
<Tooltip
|
||||
message={
|
||||
<div className="max-w-md">{getPreviewContent(type, item)}</div>
|
||||
}
|
||||
targetRef={targetRef}
|
||||
visible={visible}
|
||||
message={message}
|
||||
targetRef={linkRef}
|
||||
visible={tooltipVisible && status === "incomplete"}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Tooltip message={message} targetRef={targetRef} visible={visible} />
|
||||
)}
|
||||
</ClientOnly>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ItemNavigationButtons({
|
||||
previousUrl,
|
||||
nextUrl,
|
||||
}: {
|
||||
previousUrl: string | null;
|
||||
nextUrl: string | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{previousUrl && (
|
||||
<Link className="btn" href={previousUrl} shallow={true}>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
{nextUrl && (
|
||||
<Link className="btn" href={nextUrl} shallow={true}>
|
||||
Next
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getOrderedItems,
|
||||
getOrderedLectures,
|
||||
getNavigationLinks,
|
||||
OrderedCourseItem,
|
||||
} from "./navigationLogic";
|
||||
import { CalendarItemsInterface } from "../context/calendarItemsContext";
|
||||
|
||||
describe("navigationLogic", () => {
|
||||
const courseName = "testCourse";
|
||||
|
||||
it("getOrderedItems should order items by date, then alphabetically by name", () => {
|
||||
const createMock = (
|
||||
date: string,
|
||||
name: string,
|
||||
key: "assignments" | "quizzes" | "pages"
|
||||
) =>
|
||||
({
|
||||
[date]: { "Module 1": { [key]: [{ name }] } },
|
||||
} as unknown as CalendarItemsInterface);
|
||||
|
||||
const orderedItems = getOrderedItems(
|
||||
courseName,
|
||||
createMock("2023-01-01", "Z Assignment", "assignments"),
|
||||
createMock("2023-01-01", "A Quiz", "quizzes"),
|
||||
createMock("2023-01-02", "B Assignment", "assignments"),
|
||||
createMock("2023-01-02", "A Page", "pages")
|
||||
);
|
||||
|
||||
expect(orderedItems.map((i) => `${i.date} ${i.name}`)).toEqual([
|
||||
"2023-01-01 A Quiz",
|
||||
"2023-01-01 Z Assignment",
|
||||
"2023-01-02 A Page",
|
||||
"2023-01-02 B Assignment",
|
||||
]);
|
||||
});
|
||||
|
||||
it("getNavigationLinks should handle wrapping and normal navigation", () => {
|
||||
const items: OrderedCourseItem[] = [
|
||||
{ type: "assignment", name: "1", moduleName: "M", date: "D", url: "u1" },
|
||||
{ type: "quiz", name: "2", moduleName: "M", date: "D", url: "u2" },
|
||||
{ type: "page", name: "3", moduleName: "M", date: "D", url: "u3" },
|
||||
];
|
||||
|
||||
// Forward wrap (last -> first)
|
||||
expect(getNavigationLinks(items, "page", "3", "M").nextUrl).toBe("u1");
|
||||
|
||||
// Backward wrap (first -> last)
|
||||
expect(getNavigationLinks(items, "assignment", "1", "M").previousUrl).toBe(
|
||||
"u3"
|
||||
);
|
||||
|
||||
// Normal navigation (middle)
|
||||
const middle = getNavigationLinks(items, "quiz", "2", "M");
|
||||
expect(middle.previousUrl).toBe("u1");
|
||||
expect(middle.nextUrl).toBe("u3");
|
||||
});
|
||||
|
||||
it("getOrderedLectures should flatten weeks and generate correct URLs", () => {
|
||||
const weeks = [
|
||||
{ lectures: [{ date: "01/01/2023" }] },
|
||||
{ lectures: [{ date: "01/02/2023" }, { date: "01/03/2023" }] },
|
||||
];
|
||||
const lectures = getOrderedLectures(weeks, courseName);
|
||||
expect(lectures).toHaveLength(3);
|
||||
expect(lectures[0].url).toContain(encodeURIComponent("01/01/2023"));
|
||||
expect(lectures[0].type).toBe("lecture");
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { CalendarItemsInterface } from "../context/calendarItemsContext";
|
||||
import { getLectureUrl, getModuleItemUrl } from "@/services/urlUtils";
|
||||
|
||||
export type CourseItemType = "assignment" | "quiz" | "page" | "lecture";
|
||||
|
||||
export interface OrderedCourseItem {
|
||||
type: CourseItemType;
|
||||
name: string;
|
||||
moduleName?: string;
|
||||
date: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function getOrderedItems(
|
||||
courseName: string,
|
||||
...calendars: CalendarItemsInterface[]
|
||||
): OrderedCourseItem[] {
|
||||
const itemTypes = [
|
||||
{ key: "assignments" as const, type: "assignment" as const },
|
||||
{ key: "quizzes" as const, type: "quiz" as const },
|
||||
{ key: "pages" as const, type: "page" as const },
|
||||
];
|
||||
|
||||
return calendars
|
||||
.flatMap((calendar) =>
|
||||
Object.entries(calendar).flatMap(([date, modules]) =>
|
||||
Object.entries(modules).flatMap(([moduleName, moduleData]) =>
|
||||
itemTypes.flatMap(({ key, type }) =>
|
||||
(moduleData[key] || []).map((item) => ({
|
||||
type,
|
||||
name: item.name,
|
||||
moduleName,
|
||||
date,
|
||||
url: getModuleItemUrl(courseName, moduleName, type, item.name),
|
||||
}))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const dateCompare = a.date.localeCompare(b.date);
|
||||
if (dateCompare !== 0) return dateCompare;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
export function getOrderedLectures(
|
||||
weeks: { lectures: { date: string }[] }[],
|
||||
courseName: string
|
||||
): OrderedCourseItem[] {
|
||||
return weeks
|
||||
.flatMap((week) => week.lectures)
|
||||
.map((lecture) => ({
|
||||
type: "lecture",
|
||||
name: lecture.date,
|
||||
date: lecture.date,
|
||||
url: getLectureUrl(courseName, lecture.date),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getNavigationLinks(
|
||||
list: OrderedCourseItem[],
|
||||
type: CourseItemType,
|
||||
name: string,
|
||||
moduleName?: string
|
||||
) {
|
||||
const index = list.findIndex((item) => {
|
||||
if (type === "lecture") return item.date === name;
|
||||
return (
|
||||
item.name === name && item.type === type && item.moduleName === moduleName
|
||||
);
|
||||
});
|
||||
|
||||
if (index === -1) return { previousUrl: null, nextUrl: null };
|
||||
|
||||
const previousIndex = (index - 1 + list.length) % list.length;
|
||||
const nextIndex = (index + 1) % list.length;
|
||||
|
||||
return {
|
||||
previousUrl: list[previousIndex].url,
|
||||
nextUrl: list[nextIndex].url,
|
||||
};
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useOrderedCourseItems } from "./useOrderedCourseItems";
|
||||
import { getNavigationLinks, CourseItemType } from "./navigationLogic";
|
||||
|
||||
export function useItemNavigation(
|
||||
type: CourseItemType,
|
||||
name: string,
|
||||
moduleName?: string
|
||||
) {
|
||||
const { orderedItems, orderedLectures } = useOrderedCourseItems();
|
||||
|
||||
const list = type === "lecture" ? orderedLectures : orderedItems;
|
||||
|
||||
return getNavigationLinks(list, type, name, moduleName);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import {
|
||||
useCourseAssignmentsByModuleByDateQuery,
|
||||
useCoursePagesByModuleByDateQuery,
|
||||
useCourseQuizzesByModuleByDateQuery,
|
||||
} from "@/features/local/modules/localCourseModuleHooks";
|
||||
import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
|
||||
import { useCourseContext } from "../context/courseContext";
|
||||
import { getOrderedItems, getOrderedLectures } from "./navigationLogic";
|
||||
|
||||
export function useOrderedCourseItems() {
|
||||
const { courseName } = useCourseContext();
|
||||
const { data: weeks } = useLecturesSuspenseQuery();
|
||||
|
||||
const orderedItems = getOrderedItems(
|
||||
courseName,
|
||||
useCourseAssignmentsByModuleByDateQuery(),
|
||||
useCourseQuizzesByModuleByDateQuery(),
|
||||
useCoursePagesByModuleByDateQuery()
|
||||
);
|
||||
|
||||
const orderedLectures = getOrderedLectures(weeks, courseName);
|
||||
|
||||
return { orderedItems, orderedLectures };
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { getDateFromString } from "@/features/local/utils/timeUtils";
|
||||
import { getLectureWeekName } from "@/features/local/lectures/lectureUtils";
|
||||
import { getLecturePreviewUrl } from "@/services/urlUtils";
|
||||
import { getCourseUrl, getLecturePreviewUrl } from "@/services/urlUtils";
|
||||
import { useCourseContext } from "../../context/courseContext";
|
||||
import Link from "next/link";
|
||||
import { getDayOfWeek } from "@/features/local/course/localCourseSettings";
|
||||
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||
|
||||
export default function EditLectureTitle({
|
||||
lectureDay,
|
||||
@@ -18,7 +17,16 @@ export default function EditLectureTitle({
|
||||
const lectureWeekName = getLectureWeekName(settings.startDate, lectureDay);
|
||||
return (
|
||||
<div className="flex justify-between sm:flex-row flex-col">
|
||||
<BreadCrumbs />
|
||||
<div className="my-auto">
|
||||
<Link
|
||||
className="btn hidden sm:inline"
|
||||
href={getCourseUrl(courseName)}
|
||||
shallow={true}
|
||||
prefetch={true}
|
||||
>
|
||||
{courseName}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex justify-center ">
|
||||
<h3 className="mt-auto me-3 text-slate-500 ">Lecture</h3>
|
||||
<h1 className="">
|
||||
|
||||
@@ -9,8 +9,6 @@ import { useCourseContext } from "../../context/courseContext";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { useDeleteLectureMutation } from "@/features/local/lectures/lectureHooks";
|
||||
import Link from "next/link";
|
||||
import { useItemNavigation } from "../../hooks/useItemNavigation";
|
||||
import ItemNavigationButtons from "../../components/ItemNavigationButtons";
|
||||
|
||||
export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
|
||||
const { courseName } = useCourseContext();
|
||||
@@ -19,7 +17,6 @@ export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const modal = useModal();
|
||||
const deleteLecture = useDeleteLectureMutation();
|
||||
const { previousUrl, nextUrl } = useItemNavigation("lecture", lectureDay);
|
||||
|
||||
return (
|
||||
<div className="p-5 flex flex-row justify-end gap-3">
|
||||
@@ -64,7 +61,6 @@ export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
|
||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||
Go Back
|
||||
</Link>
|
||||
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||
{isLoading && <Spinner />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function LecturePreview({ lecture }: { lecture: Lecture }) {
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<MarkdownDisplay markdown={lecture.content} convertImages={false} />
|
||||
<MarkdownDisplay markdown={lecture.content} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import LecturePreview from "../LecturePreview";
|
||||
import { getCourseUrl, getLectureUrl } from "@/services/urlUtils";
|
||||
import { useCourseContext } from "../../../context/courseContext";
|
||||
import Link from "next/link";
|
||||
import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
|
||||
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||
|
||||
export default function LecturePreviewPage({
|
||||
lectureDay,
|
||||
}: {
|
||||
lectureDay: string;
|
||||
}) {
|
||||
const { courseName } = useCourseContext();
|
||||
const { data: weeks } = useLecturesSuspenseQuery();
|
||||
const lecture = weeks
|
||||
.flatMap(({ lectures }) => lectures.map((lecture) => lecture))
|
||||
@@ -20,7 +23,20 @@ export default function LecturePreviewPage({
|
||||
return (
|
||||
<div className="flex h-full xl:flex-row flex-col ">
|
||||
<div className="flex-shrink flex-1 pb-1 ms-3 xl:ms-0 flex flex-row flex-wrap gap-3 content-start ">
|
||||
<BreadCrumbs />
|
||||
<div className="">
|
||||
<Link
|
||||
className="btn"
|
||||
href={getLectureUrl(courseName, lectureDay)}
|
||||
shallow={true}
|
||||
>
|
||||
Edit Lecture
|
||||
</Link>
|
||||
</div>
|
||||
<div className="">
|
||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||
Course Calendar
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center min-h-0 px-2">
|
||||
<div
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function ExpandableModule({
|
||||
</ClientOnly>
|
||||
<ExpandIcon
|
||||
style={{
|
||||
...(isExpanded ? { rotate: "90deg" } : {rotate: "180deg"}),
|
||||
...(isExpanded ? { rotate: "-90deg" } : {}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
getDateFromStringOrThrow,
|
||||
} from "@/features/local/utils/timeUtils";
|
||||
import { useCreateAssignmentMutation } from "@/features/local/assignments/assignmentHooks";
|
||||
import { validateFileName } from "@/services/fileNameValidation";
|
||||
|
||||
export default function NewItemForm({
|
||||
moduleName: defaultModuleName,
|
||||
@@ -40,13 +39,6 @@ export default function NewItemForm({
|
||||
);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [nameError, setNameError] = useState("");
|
||||
|
||||
const handleNameChange = (newName: string) => {
|
||||
setName(newName);
|
||||
const error = validateFileName(newName);
|
||||
setNameError(error);
|
||||
};
|
||||
|
||||
const defaultDate = getDateFromString(
|
||||
creationDate ? creationDate : dateToMarkdownString(new Date())
|
||||
@@ -73,12 +65,6 @@ export default function NewItemForm({
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate name before submission
|
||||
if (nameError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dueAt =
|
||||
dueDate === ""
|
||||
? dueDate
|
||||
@@ -174,16 +160,7 @@ export default function NewItemForm({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TextInput
|
||||
label={type + " Name"}
|
||||
value={name}
|
||||
setValue={handleNameChange}
|
||||
/>
|
||||
{nameError && (
|
||||
<div className="text-red-300 bg-red-950/50 border p-1 rounded border-red-900/50 text-sm mt-1">
|
||||
{nameError}
|
||||
</div>
|
||||
)}
|
||||
<TextInput label={type + " Name"} value={name} setValue={setName} />
|
||||
</div>
|
||||
<div>
|
||||
{type !== "Page" && (
|
||||
@@ -201,9 +178,7 @@ export default function NewItemForm({
|
||||
No assignment groups created, create them in the course settings page
|
||||
</div>
|
||||
)}
|
||||
<button disabled={!!nameError} type="submit">
|
||||
Create
|
||||
</button>
|
||||
<button type="submit">Create</button>
|
||||
{isPending && <Spinner />}
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -17,8 +17,6 @@ import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useItemNavigation } from "../../../../hooks/useItemNavigation";
|
||||
import ItemNavigationButtons from "../../../../components/ItemNavigationButtons";
|
||||
|
||||
export function AssignmentFooterButtons({
|
||||
moduleName,
|
||||
@@ -44,11 +42,6 @@ export function AssignmentFooterButtons({
|
||||
const deleteLocal = useDeleteAssignmentMutation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const modal = useModal();
|
||||
const { previousUrl, nextUrl } = useItemNavigation(
|
||||
"assignment",
|
||||
assignmentName,
|
||||
moduleName
|
||||
);
|
||||
|
||||
const assignmentInCanvas = canvasAssignments?.find(
|
||||
(a) => a.name === assignmentName
|
||||
@@ -162,7 +155,6 @@ export function AssignmentFooterButtons({
|
||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||
Go Back
|
||||
</Link>
|
||||
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
|
||||
import { UpdateAssignmentName } from "./UpdateAssignmentName";
|
||||
import { RightSingleChevron } from "@/components/icons/RightSingleChevron";
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function EditAssignmentHeader({
|
||||
moduleName,
|
||||
@@ -9,21 +10,22 @@ export default function EditAssignmentHeader({
|
||||
assignmentName: string;
|
||||
moduleName: string;
|
||||
}) {
|
||||
const { courseName } = useCourseContext();
|
||||
return (
|
||||
<div className="py-1 flex flex-row justify-between">
|
||||
<div className="flex flex-row">
|
||||
<BreadCrumbs />
|
||||
<span className="text-slate-500 cursor-default select-none my-auto">
|
||||
<RightSingleChevron />
|
||||
</span>
|
||||
<div className="my-auto px-3">{assignmentName}</div>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<div className="py-1 flex flex-row justify-start gap-3">
|
||||
<Link
|
||||
className="btn"
|
||||
href={getCourseUrl(courseName)}
|
||||
shallow={true}
|
||||
prefetch={true}
|
||||
>
|
||||
{courseName}
|
||||
</Link>
|
||||
<UpdateAssignmentName
|
||||
assignmentName={assignmentName}
|
||||
moduleName={moduleName}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-auto">{assignmentName}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ export function UpdateAssignmentName({
|
||||
if (name === assignmentName) closeModal();
|
||||
|
||||
setIsLoading(true); // page refresh resets flag
|
||||
try {
|
||||
await updateAssignment.mutateAsync({
|
||||
assignment: assignment,
|
||||
moduleName,
|
||||
@@ -55,22 +54,8 @@ export function UpdateAssignmentName({
|
||||
getModuleItemUrl(courseName, moduleName, "assignment", name),
|
||||
{}
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="
|
||||
text-yellow-300
|
||||
bg-yellow-950/30
|
||||
border-2
|
||||
rounded-lg
|
||||
border-yellow-800
|
||||
p-1 text-sm mb-2"
|
||||
>
|
||||
Warning: does not rename in Canvas
|
||||
</div>
|
||||
<TextInput
|
||||
value={name}
|
||||
setValue={setName}
|
||||
|
||||
@@ -102,13 +102,13 @@ export default function EditPage({
|
||||
<EditLayout
|
||||
Header={<EditPageHeader pageName={pageName} moduleName={moduleName} />}
|
||||
Body={
|
||||
<div className="flex min-h-0 flex-1 gap-4 overflow-hidden">
|
||||
<div className="flex-1 h-full min-w-0 overflow-hidden">
|
||||
<div className="columns-2 min-h-0 flex-1">
|
||||
<div className="flex-1 h-full">
|
||||
<MonacoEditor key={monacoKey} value={text} onChange={textUpdate} />
|
||||
</div>
|
||||
<div className="flex-1 h-full min-w-0 flex flex-col overflow-hidden">
|
||||
<div className="h-full">
|
||||
<div className="text-red-300">{error && error}</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<br />
|
||||
<PagePreview page={page} />
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,6 @@ import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { useItemNavigation } from "../../../../hooks/useItemNavigation";
|
||||
import ItemNavigationButtons from "../../../../components/ItemNavigationButtons";
|
||||
|
||||
export default function EditPageButtons({
|
||||
moduleName,
|
||||
@@ -38,11 +36,6 @@ export default function EditPageButtons({
|
||||
const deletePageLocal = useDeletePageMutation();
|
||||
const modal = useModal();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { previousUrl, nextUrl } = useItemNavigation(
|
||||
"page",
|
||||
pageName,
|
||||
moduleName
|
||||
);
|
||||
|
||||
const pageInCanvas = canvasPages?.find((p) => p.title === pageName);
|
||||
|
||||
@@ -132,7 +125,6 @@ export default function EditPageButtons({
|
||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||
Go Back
|
||||
</Link>
|
||||
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { UpdatePageName } from "./UpdatePageName";
|
||||
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||
import { RightSingleChevron } from "@/components/icons/RightSingleChevron";
|
||||
|
||||
export default function EditPageHeader({
|
||||
moduleName,
|
||||
@@ -9,18 +10,19 @@ export default function EditPageHeader({
|
||||
pageName: string;
|
||||
moduleName: string;
|
||||
}) {
|
||||
const { courseName } = useCourseContext();
|
||||
return (
|
||||
<div className="py-1 flex flex-row justify-between">
|
||||
<div className="flex flex-row">
|
||||
<BreadCrumbs />
|
||||
<span className="text-slate-500 cursor-default select-none my-auto">
|
||||
<RightSingleChevron />
|
||||
</span>
|
||||
<div className="my-auto px-3">{pageName}</div>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<div className="py-1 flex flex-row justify-start gap-3">
|
||||
<Link
|
||||
className="btn"
|
||||
href={getCourseUrl(courseName)}
|
||||
shallow={true}
|
||||
prefetch={true}
|
||||
>
|
||||
{courseName}
|
||||
</Link>
|
||||
<UpdatePageName pageName={pageName} moduleName={moduleName} />
|
||||
</div>
|
||||
<div className="my-auto">{pageName}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,17 +56,6 @@ export function UpdatePageName({
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="
|
||||
text-yellow-300
|
||||
bg-yellow-950/30
|
||||
border-2
|
||||
rounded-lg
|
||||
border-yellow-800
|
||||
p-1 text-sm mb-2"
|
||||
>
|
||||
Warning: does not rename in Canvas
|
||||
</div>
|
||||
<TextInput value={name} setValue={setName} label={"Rename Page"} />
|
||||
<button className="w-full my-3">Save New Name</button>
|
||||
{isLoading && <Spinner />}
|
||||
|
||||
@@ -15,9 +15,6 @@ import { useAuthoritativeUpdates } from "../../../../utils/useAuthoritativeUpdat
|
||||
import { extractLabelValue } from "@/features/local/assignments/models/utils/markdownUtils";
|
||||
import EditQuizHeader from "./EditQuizHeader";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { useGlobalSettingsQuery } from "@/features/local/globalSettings/globalSettingsHooks";
|
||||
import { getFeedbackDelimitersFromSettings } from "@/features/local/globalSettings/globalSettingsUtils";
|
||||
import type { GlobalSettings } from "@/features/local/globalSettings/globalSettingsModels";
|
||||
import { EditLayout } from "@/components/EditLayout";
|
||||
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
|
||||
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
||||
@@ -73,29 +70,7 @@ this is a matching question
|
||||
^ left answer - right dropdown
|
||||
^ other thing - another option
|
||||
^ - distractor
|
||||
^ - other distractor
|
||||
---
|
||||
Points: 3
|
||||
FEEDBACK EXAMPLE
|
||||
What is 2+3?
|
||||
+ Correct! Good job
|
||||
- Incorrect, try again
|
||||
... This is general feedback shown regardless
|
||||
*a) 4
|
||||
*b) 5
|
||||
c) 6
|
||||
---
|
||||
Points: 2
|
||||
FEEDBACK EXAMPLE
|
||||
Multiline feedback example
|
||||
+
|
||||
Great work!
|
||||
You understand the concept.
|
||||
-
|
||||
Not quite right.
|
||||
Review the material and try again.
|
||||
*a) correct answer
|
||||
b) wrong answer`;
|
||||
^ - other distractor`;
|
||||
};
|
||||
|
||||
export default function EditQuiz({
|
||||
@@ -114,15 +89,10 @@ export default function EditQuiz({
|
||||
isFetching,
|
||||
} = useQuizQuery(moduleName, quizName);
|
||||
const updateQuizMutation = useUpdateQuizMutation();
|
||||
const { data: globalSettings } = useGlobalSettingsQuery();
|
||||
const feedbackDelimiters = getFeedbackDelimitersFromSettings(
|
||||
(globalSettings ?? ({} as GlobalSettings)) as GlobalSettings
|
||||
);
|
||||
|
||||
const { clientIsAuthoritative, text, textUpdate, monacoKey } =
|
||||
useAuthoritativeUpdates({
|
||||
serverUpdatedAt: serverDataUpdatedAt,
|
||||
startingText: quizMarkdownUtils.toMarkdown(quiz, feedbackDelimiters),
|
||||
startingText: quizMarkdownUtils.toMarkdown(quiz),
|
||||
});
|
||||
|
||||
const [error, setError] = useState("");
|
||||
@@ -138,18 +108,13 @@ export default function EditQuiz({
|
||||
try {
|
||||
const name = extractLabelValue(text, "Name");
|
||||
if (
|
||||
quizMarkdownUtils.toMarkdown(quiz, feedbackDelimiters) !==
|
||||
quizMarkdownUtils.toMarkdown(quiz) !==
|
||||
quizMarkdownUtils.toMarkdown(
|
||||
quizMarkdownUtils.parseMarkdown(text, name, feedbackDelimiters),
|
||||
feedbackDelimiters
|
||||
quizMarkdownUtils.parseMarkdown(text, name)
|
||||
)
|
||||
) {
|
||||
if (clientIsAuthoritative) {
|
||||
const updatedQuiz = quizMarkdownUtils.parseMarkdown(
|
||||
text,
|
||||
quizName,
|
||||
feedbackDelimiters
|
||||
);
|
||||
const updatedQuiz = quizMarkdownUtils.parseMarkdown(text, quizName);
|
||||
await updateQuizMutation.mutateAsync({
|
||||
quiz: updatedQuiz,
|
||||
moduleName,
|
||||
@@ -194,7 +159,7 @@ export default function EditQuiz({
|
||||
Body={
|
||||
<>
|
||||
{showHelp && (
|
||||
<pre className=" max-w-96 h-full overflow-y-auto">
|
||||
<pre className=" max-w-96">
|
||||
<code>{helpString(settings)}</code>
|
||||
</pre>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RightSingleChevron } from "@/components/icons/RightSingleChevron";
|
||||
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { UpdateQuizName } from "./UpdateQuizName";
|
||||
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||
|
||||
export default function EditQuizHeader({
|
||||
moduleName,
|
||||
@@ -9,18 +10,19 @@ export default function EditQuizHeader({
|
||||
quizName: string;
|
||||
moduleName: string;
|
||||
}) {
|
||||
const { courseName } = useCourseContext();
|
||||
return (
|
||||
<div className="py-1 flex flex-row justify-between">
|
||||
<div className="flex flex-row">
|
||||
<BreadCrumbs />
|
||||
<span className="text-slate-500 cursor-default select-none my-auto">
|
||||
<RightSingleChevron />
|
||||
</span>
|
||||
<div className="my-auto px-3">{quizName}</div>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<div className="py-1 flex flex-row justify-start gap-3">
|
||||
<Link
|
||||
className="btn"
|
||||
href={getCourseUrl(courseName)}
|
||||
shallow={true}
|
||||
prefetch={true}
|
||||
>
|
||||
{courseName}
|
||||
</Link>
|
||||
<UpdateQuizName quizName={quizName} moduleName={moduleName} />
|
||||
</div>
|
||||
<div>{quizName}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useItemNavigation } from "../../../../hooks/useItemNavigation";
|
||||
import ItemNavigationButtons from "../../../../components/ItemNavigationButtons";
|
||||
|
||||
export function QuizButtons({
|
||||
moduleName,
|
||||
@@ -37,11 +35,6 @@ export function QuizButtons({
|
||||
const deleteFromCanvas = useDeleteQuizFromCanvasMutation();
|
||||
const deleteLocal = useDeleteQuizMutation();
|
||||
const modal = useModal();
|
||||
const { previousUrl, nextUrl } = useItemNavigation(
|
||||
"quiz",
|
||||
quizName,
|
||||
moduleName
|
||||
);
|
||||
|
||||
const quizInCanvas = canvasQuizzes?.find((c) => c.title === quizName);
|
||||
|
||||
@@ -118,7 +111,6 @@ export function QuizButtons({
|
||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||
Go Back
|
||||
</Link>
|
||||
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -80,45 +80,6 @@ function QuizQuestionPreview({ question }: { question: LocalQuizQuestion }) {
|
||||
</div>
|
||||
</div>
|
||||
<MarkdownDisplay markdown={question.text} className="ms-4 mb-2" />
|
||||
|
||||
{/* Feedback Section */}
|
||||
{(question.correctComments ||
|
||||
question.incorrectComments ||
|
||||
question.neutralComments) && (
|
||||
<div className=" m-2 ps-2 py-1 rounded flex bg-slate-950/50">
|
||||
<div>Feedback</div>
|
||||
<div className="mx-4 space-y-1">
|
||||
{question.correctComments && (
|
||||
<div className="border-l-2 border-green-700 pl-2 py-1 flex">
|
||||
<span className="text-green-500">+ </span>
|
||||
<MarkdownDisplay
|
||||
markdown={question.correctComments}
|
||||
className="ms-4 mb-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{question.incorrectComments && (
|
||||
<div className="border-l-2 border-red-700 pl-2 py-1 flex">
|
||||
<span className="text-red-500">- </span>
|
||||
<MarkdownDisplay
|
||||
markdown={question.incorrectComments}
|
||||
className="ms-4 mb-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{question.neutralComments && (
|
||||
<div className="border-l-2 border-blue-800 pl-2 py-1 flex">
|
||||
<span className="text-blue-500">... </span>
|
||||
<MarkdownDisplay
|
||||
markdown={question.neutralComments}
|
||||
className="ms-4 mb-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.questionType === QuestionType.MATCHING && (
|
||||
<div>
|
||||
{question.answers.map((answer) => (
|
||||
|
||||
@@ -56,17 +56,6 @@ export function UpdateQuizName({
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="
|
||||
text-yellow-300
|
||||
bg-yellow-950/30
|
||||
border-2
|
||||
rounded-lg
|
||||
border-yellow-800
|
||||
p-1 text-sm mb-2"
|
||||
>
|
||||
Warning: does not rename in Canvas
|
||||
</div>
|
||||
<TextInput value={name} setValue={setName} label={"Rename Quiz"} />
|
||||
<button className="w-full my-3">Save New Name</button>
|
||||
{isLoading && <Spinner />}
|
||||
|
||||
@@ -20,7 +20,7 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<head></head>
|
||||
<body className="flex justify-center h-screen" suppressHydrationWarning>
|
||||
<body className="flex justify-center">
|
||||
<div className="bg-gray-950 h-screen text-slate-300 w-screen sm:p-1">
|
||||
<MyToaster />
|
||||
<Suspense>
|
||||
|
||||
@@ -20,8 +20,6 @@ export default async function Home() {
|
||||
<br />
|
||||
<br />
|
||||
<AddCourseToGlobalSettings />
|
||||
<br />
|
||||
<div className="mb-96">
|
||||
<AddExistingCourseToGlobalSettings />
|
||||
<br />
|
||||
<br />
|
||||
@@ -43,7 +41,14 @@ export default async function Home() {
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,6 @@ export function makeQueryClient() {
|
||||
// refetchOnMount: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
onError: (error) => {
|
||||
const message = getAxiosErrorMessage(error as AxiosError);
|
||||
console.error("Mutation error:", message);
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import HomeIcon from "./icons/HomeIcon";
|
||||
import { RightSingleChevron } from "./icons/RightSingleChevron";
|
||||
|
||||
export const BreadCrumbs = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const pathSegments = pathname?.split("/").filter(Boolean) || [];
|
||||
const isCourseRoute = pathSegments[0] === "course";
|
||||
|
||||
const courseName =
|
||||
isCourseRoute && pathSegments[1]
|
||||
? decodeURIComponent(pathSegments[1])
|
||||
: null;
|
||||
|
||||
const isLectureRoute = isCourseRoute && pathSegments[2] === "lecture";
|
||||
const lectureDate =
|
||||
isLectureRoute && pathSegments[3]
|
||||
? decodeURIComponent(pathSegments[3])
|
||||
: null;
|
||||
|
||||
const lectureDateOnly = lectureDate
|
||||
? (() => {
|
||||
const dateStr = lectureDate.split(" ")[0];
|
||||
const date = new Date(dateStr);
|
||||
const month = date.toLocaleDateString("en-US", { month: "short" });
|
||||
const day = date.getDate();
|
||||
return `${month} ${day}`;
|
||||
})()
|
||||
: null;
|
||||
|
||||
const sharedBackgroundClassNames = `
|
||||
group
|
||||
hover:bg-blue-900/30
|
||||
rounded-lg
|
||||
h-full
|
||||
flex
|
||||
items-center
|
||||
transition
|
||||
`;
|
||||
const sharedLinkClassNames = `
|
||||
text-slate-300
|
||||
transition
|
||||
group-hover:text-slate-100
|
||||
rounded-lg
|
||||
h-full
|
||||
flex
|
||||
items-center
|
||||
px-3
|
||||
`;
|
||||
|
||||
return (
|
||||
<nav className="flex flex-row font-bold text-sm items-center">
|
||||
<span className={sharedBackgroundClassNames}>
|
||||
<Link
|
||||
href="/"
|
||||
shallow={true}
|
||||
className="flex items-center gap-1 rounded-lg h-full "
|
||||
>
|
||||
<span className={sharedLinkClassNames}>
|
||||
<HomeIcon />
|
||||
</span>
|
||||
</Link>
|
||||
</span>
|
||||
|
||||
{courseName && (
|
||||
<>
|
||||
<span className="text-slate-500 cursor-default select-none">
|
||||
<RightSingleChevron />
|
||||
</span>
|
||||
<span className={sharedBackgroundClassNames}>
|
||||
<Link
|
||||
href={`/course/${encodeURIComponent(courseName)}`}
|
||||
shallow={true}
|
||||
className={sharedLinkClassNames}
|
||||
>
|
||||
{courseName}
|
||||
</Link>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isLectureRoute && lectureDate && courseName && (
|
||||
<>
|
||||
<span className="text-slate-500 cursor-default select-none">
|
||||
<RightSingleChevron />
|
||||
</span>
|
||||
<span className={sharedBackgroundClassNames}>
|
||||
<Link
|
||||
href={`/course/${encodeURIComponent(
|
||||
courseName
|
||||
)}/lecture/${encodeURIComponent(lectureDate)}`}
|
||||
shallow={true}
|
||||
className={sharedLinkClassNames}
|
||||
>
|
||||
{lectureDateOnly}
|
||||
</Link>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,6 @@ export default function MarkdownDisplay({
|
||||
markdown,
|
||||
className = "",
|
||||
replaceText = [],
|
||||
convertImages,
|
||||
}: {
|
||||
markdown: string;
|
||||
className?: string;
|
||||
@@ -15,7 +14,6 @@ export default function MarkdownDisplay({
|
||||
source: string;
|
||||
destination: string;
|
||||
}[];
|
||||
convertImages?: boolean;
|
||||
}) {
|
||||
const { data: settings } = useLocalCourseSettingsQuery();
|
||||
return (
|
||||
@@ -25,7 +23,6 @@ export default function MarkdownDisplay({
|
||||
settings={settings}
|
||||
className={className}
|
||||
replaceText={replaceText}
|
||||
convertImages={convertImages}
|
||||
/>
|
||||
</SuspenseAndErrorHandling>
|
||||
);
|
||||
@@ -36,7 +33,6 @@ function DangerousInnerMarkdown({
|
||||
settings,
|
||||
className,
|
||||
replaceText,
|
||||
convertImages,
|
||||
}: {
|
||||
markdown: string;
|
||||
settings: LocalCourseSettings;
|
||||
@@ -45,7 +41,6 @@ function DangerousInnerMarkdown({
|
||||
source: string;
|
||||
destination: string;
|
||||
}[];
|
||||
convertImages?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -53,7 +48,6 @@ function DangerousInnerMarkdown({
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: markdownToHTMLSafe({
|
||||
markdownString: markdown,
|
||||
convertImages,
|
||||
settings,
|
||||
replaceText,
|
||||
}),
|
||||
|
||||
@@ -25,39 +25,37 @@ export function useModal() {
|
||||
|
||||
export default function Modal({
|
||||
children,
|
||||
buttonText = "",
|
||||
buttonText,
|
||||
buttonClass = "",
|
||||
modalWidth = "w-1/3",
|
||||
modalControl,
|
||||
buttonComponent,
|
||||
}: {
|
||||
children: (props: { closeModal: () => void }) => ReactNode;
|
||||
buttonText?: string;
|
||||
buttonText: string;
|
||||
buttonClass?: string;
|
||||
modalWidth?: string;
|
||||
modalControl: ModalControl;
|
||||
buttonComponent?: (props: { openModal: () => void }) => ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{buttonComponent ? (
|
||||
buttonComponent({ openModal: modalControl.openModal })
|
||||
) : (
|
||||
<button onClick={modalControl.openModal} className={buttonClass}>
|
||||
{buttonText}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
modalControl.isOpen
|
||||
? "transition-all duration-400 fixed inset-0 flex items-center justify-center h-screen bg-black/80 z-50 w-screen"
|
||||
: "hidden h-0 w-0 p-1 -z-50"
|
||||
" fixed inset-0 flex items-center justify-center transition-all duration-400 h-screen w-screen " +
|
||||
" bg-black" +
|
||||
(modalControl.isOpen
|
||||
? " bg-opacity-50 z-50 "
|
||||
: " bg-opacity-0 -z-50 ")
|
||||
}
|
||||
onClick={modalControl.closeModal}
|
||||
// if mouse up here, do not, if mouse down then still do
|
||||
>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
// e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={
|
||||
|
||||
@@ -18,10 +18,10 @@ export const Tooltip: React.FC<{
|
||||
" absolute -translate-x-1/2 " +
|
||||
" bg-gray-900 text-slate-200 text-sm " +
|
||||
" rounded-md py-1 px-2 " +
|
||||
" transition-opacity duration-150 " +
|
||||
" transition-all duration-400 " +
|
||||
" border border-slate-700 shadow-[0px_0px_10px_5px] shadow-slate-500/20 " +
|
||||
" max-w-sm max-h-64 overflow-hidden " +
|
||||
(visible ? " opacity-100 " : " opacity-0 pointer-events-none hidden ")
|
||||
(visible ? " " : " hidden -z-50 ")
|
||||
}
|
||||
role="tooltip"
|
||||
>
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
background-color: #030712 !important;
|
||||
/* background-color: #101828 !important; */
|
||||
}
|
||||
.sticky-widget {
|
||||
background-color: #0C0F17 !important;
|
||||
}
|
||||
.monaco-editor {
|
||||
position: absolute !important;
|
||||
}
|
||||
|
||||
@@ -100,8 +100,6 @@ export function StoragePathSelector({
|
||||
setArrowUsed(false);
|
||||
setHighlightedIndex(-1);
|
||||
if (shouldFocus) {
|
||||
// Keep the dropdown open by maintaining focus state
|
||||
setIsFocused(true);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
};
|
||||
@@ -156,10 +154,7 @@ export function StoragePathSelector({
|
||||
className={`dropdown-option w-full px-2 py-1 cursor-pointer ${
|
||||
highlightedIndex === idx ? "bg-blue-700 text-white" : ""
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // Prevent input blur
|
||||
handleSelectFolder(option, true);
|
||||
}}
|
||||
onMouseDown={() => handleSelectFolder(option)}
|
||||
onMouseEnter={() => setHighlightedIndex(idx)}
|
||||
>
|
||||
{option}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
export const Toggle: FC<{
|
||||
label: string;
|
||||
value: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}> = ({ label, value, onChange }) => {
|
||||
return (
|
||||
<label
|
||||
className="
|
||||
flex align-middle p-2 cursor-pointer
|
||||
text-gray-300
|
||||
hover:text-blue-400
|
||||
transition-colors duration-200 ease-in-out
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="appearance-none peer"
|
||||
checked={value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span
|
||||
className={`
|
||||
w-12 h-6 flex items-center flex-shrink-0 mx-3 p-1
|
||||
bg-gray-600 rounded-full
|
||||
duration-300 ease-in-out
|
||||
peer-checked:bg-blue-600
|
||||
after:w-4 after:h-4 after:bg-white after:rounded-full after:shadow-md
|
||||
after:duration-300 peer-checked:after:translate-x-6
|
||||
group-hover:after:translate-x-1
|
||||
`}
|
||||
></span>
|
||||
<span className="">{label}</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -1,34 +1,26 @@
|
||||
import React from "react";
|
||||
|
||||
export default function ExpandIcon({
|
||||
style,
|
||||
}: {
|
||||
export default function ExpandIcon({style}: {
|
||||
style?: React.CSSProperties | undefined;
|
||||
}) {
|
||||
const size = "24px";
|
||||
return (
|
||||
<svg
|
||||
style={style}
|
||||
|
||||
width={size}
|
||||
height={size}
|
||||
className="transition-all"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 17 17"
|
||||
version="1.1"
|
||||
className="si-glyph si-glyph-triangle-left transition-all ms-1"
|
||||
>
|
||||
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
||||
<g
|
||||
id="SVGRepo_tracerCarrier"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></g>
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
||||
<path
|
||||
className="stroke-slate-300"
|
||||
d="M9 6L15 12L9 18"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.446,10.052 C2.866,9.471 2.866,8.53 3.446,7.948 L9.89,1.506 C10.471,0.924 11.993,0.667 11.993,2.506 L11.993,15.494 C11.993,17.395 10.472,17.076 9.89,16.495 L3.446,10.052 L3.446,10.052 Z"
|
||||
className="si-glyph-fill"
|
||||
style={{
|
||||
fill: "rgb(148 163 184 / var(--tw-text-opacity))",
|
||||
}}
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
// https://www.svgrepo.com/collection/wolf-kit-solid-glyph-icons/?search=home
|
||||
export default function HomeIcon() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
||||
<g
|
||||
id="SVGRepo_tracerCarrier"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></g>
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<path d="M11.3861 1.21065C11.7472 0.929784 12.2528 0.929784 12.6139 1.21065L21.6139 8.21065C21.8575 8.4001 22 8.69141 22 9V20.5C22 21.3284 21.3284 22 20.5 22H15V14C15 13.4477 14.5523 13 14 13H10C9.44772 13 9 13.4477 9 14V22H3.5C2.67157 22 2 21.3284 2 20.5V9C2 8.69141 2.14247 8.4001 2.38606 8.21065L11.3861 1.21065Z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
// https://www.svgrepo.com/svg/491374/chevron-small-right
|
||||
export const RightSingleChevron = () => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="7 4 11 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-3 w-3 fill-slate-600"
|
||||
>
|
||||
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
||||
<g
|
||||
id="SVGRepo_tracerCarrier"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></g>
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.08586 5.41412C7.69534 5.80465 7.69534 6.43781 8.08586 6.82834L13.3788 12.1212L8.08586 17.4141C7.69534 17.8046 7.69534 18.4378 8.08586 18.8283L8.79297 19.5354C9.18349 19.926 9.81666 19.926 10.2072 19.5354L16.5607 13.1819C17.1465 12.5961 17.1465 11.6464 16.5607 11.0606L10.2072 4.70702C9.81666 4.31649 9.18349 4.31649 8.79297 4.70702L8.08586 5.41412Z"
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -4,8 +4,6 @@ import { useTRPC } from "@/services/serverFunctions/trpcClient";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useGlobalSettingsQuery } from "@/features/local/globalSettings/globalSettingsHooks";
|
||||
import { GlobalSettings } from "@/features/local/globalSettings/globalSettingsModels";
|
||||
|
||||
interface ServerToClientEvents {
|
||||
message: (data: string) => void;
|
||||
@@ -26,22 +24,6 @@ function removeFileExtension(fileName: string): string {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
function getCourseNameByPath(
|
||||
filePath: string,
|
||||
settings: GlobalSettings
|
||||
) {
|
||||
const courseSettings = settings.courses.find((c) => {
|
||||
const normalizedFilePath = filePath.startsWith("./")
|
||||
? filePath.substring(2)
|
||||
: filePath;
|
||||
const normalizedCoursePath = c.path.startsWith("./")
|
||||
? c.path.substring(2)
|
||||
: c.path;
|
||||
return normalizedFilePath.startsWith(normalizedCoursePath);
|
||||
});
|
||||
return courseSettings?.name;
|
||||
}
|
||||
|
||||
export function ClientCacheInvalidation() {
|
||||
const invalidateCache = useFilePathInvalidation();
|
||||
const [connectionAttempted, setConnectionAttempted] = useState(false);
|
||||
@@ -80,32 +62,13 @@ export function ClientCacheInvalidation() {
|
||||
const useFilePathInvalidation = () => {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: settings } = useGlobalSettingsQuery();
|
||||
|
||||
return useCallback(
|
||||
(filePath: string) => {
|
||||
const courseName = getCourseNameByPath(filePath, settings);
|
||||
// console.log(filePath, settings, courseName);
|
||||
if (!courseName) {
|
||||
console.log(
|
||||
"no course settings found for file path, not invalidating cache",
|
||||
filePath
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const splitPath = filePath.split("/");
|
||||
const [moduleOrLectures, itemType, itemFile] = splitPath.slice(-3);
|
||||
const [courseName, moduleOrLectures, itemType, itemFile] =
|
||||
filePath.split("/");
|
||||
|
||||
const itemName = itemFile ? removeFileExtension(itemFile) : undefined;
|
||||
const allParts = { courseName, moduleOrLectures, itemType, itemName };
|
||||
// console.log(
|
||||
// "received file to invalidate",
|
||||
// filePath,
|
||||
// allParts,
|
||||
// itemName,
|
||||
// itemType
|
||||
// );
|
||||
const allParts = [courseName, moduleOrLectures, itemType, itemName];
|
||||
|
||||
if (moduleOrLectures === "settings.yml") {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -178,8 +141,6 @@ const useFilePathInvalidation = () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("no cache invalidation match for file ", allParts);
|
||||
},
|
||||
[
|
||||
queryClient,
|
||||
@@ -192,7 +153,6 @@ const useFilePathInvalidation = () => {
|
||||
trpc.quiz.getQuiz,
|
||||
trpc.settings.allCoursesSettings,
|
||||
trpc.settings.courseSettings,
|
||||
settings,
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
|
||||
export const useTooltip = (delayMs: number = 150) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const targetRef = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const showTooltip = useCallback(() => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setVisible(true);
|
||||
}, delayMs);
|
||||
}, [delayMs]);
|
||||
|
||||
const hideTooltip = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
setVisible(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
visible,
|
||||
targetRef,
|
||||
showTooltip,
|
||||
hideTooltip,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
|
||||
import { CanvasAssignmentGroup } from "@/features/canvas/models/assignments/canvasAssignmentGroup";
|
||||
import { LocalAssignmentGroup } from "@/features/local/assignments/models/localAssignmentGroup";
|
||||
import { rateLimitAwareDelete, rateLimitAwarePost } from "./canvasWebRequestUtils";
|
||||
import { rateLimitAwareDelete } from "./canvasWebRequestor";
|
||||
import { axiosClient } from "@/services/axiosUtils";
|
||||
|
||||
export const canvasAssignmentGroupService = {
|
||||
@@ -26,7 +26,7 @@ export const canvasAssignmentGroupService = {
|
||||
};
|
||||
|
||||
const { data: canvasAssignmentGroup } =
|
||||
await rateLimitAwarePost<CanvasAssignmentGroup>(url, body);
|
||||
await axiosClient.post<CanvasAssignmentGroup>(url, body);
|
||||
|
||||
return {
|
||||
...localAssignmentGroup,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { getRubricCriterion } from "./canvasRubricUtils";
|
||||
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
||||
import { axiosClient } from "@/services/axiosUtils";
|
||||
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
|
||||
import { rateLimitAwarePost } from "./canvasWebRequestUtils";
|
||||
|
||||
export const canvasAssignmentService = {
|
||||
async getAll(courseId: number): Promise<CanvasAssignment[]> {
|
||||
@@ -61,7 +60,7 @@ export const canvasAssignmentService = {
|
||||
},
|
||||
};
|
||||
|
||||
const response = await rateLimitAwarePost<CanvasAssignment>(url, body);
|
||||
const response = await axiosClient.post<CanvasAssignment>(url, body);
|
||||
const canvasAssignment = response.data;
|
||||
|
||||
await createRubric(canvasCourseId, canvasAssignment.id, localAssignment);
|
||||
@@ -153,7 +152,7 @@ const createRubric = async (
|
||||
};
|
||||
|
||||
const rubricUrl = `${canvasApi}/courses/${courseId}/rubrics`;
|
||||
const rubricResponse = await rateLimitAwarePost<CanvasRubricCreationResponse>(
|
||||
const rubricResponse = await axiosClient.post<CanvasRubricCreationResponse>(
|
||||
rubricUrl,
|
||||
rubricBody
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { CanvasPage } from "@/features/canvas/models/pages/canvasPageModel";
|
||||
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
|
||||
import { CanvasModule } from "@/features/canvas/models/modules/canvasModule";
|
||||
import { axiosClient } from "@/services/axiosUtils";
|
||||
import { rateLimitAwarePost } from "./canvasWebRequestUtils";
|
||||
|
||||
export const canvasModuleService = {
|
||||
async updateModuleItem(
|
||||
@@ -38,7 +37,7 @@ export const canvasModuleService = {
|
||||
console.log(`Creating new module item ${title}`);
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/modules/${canvasModuleId}/items`;
|
||||
const body = { module_item: { title, type, content_id: contentId } };
|
||||
await rateLimitAwarePost(url, body);
|
||||
await axiosClient.post(url, body);
|
||||
},
|
||||
|
||||
async createPageModuleItem(
|
||||
@@ -52,7 +51,7 @@ export const canvasModuleService = {
|
||||
const body = {
|
||||
module_item: { title, type: "Page", page_url: canvasPage.url },
|
||||
};
|
||||
await rateLimitAwarePost<CanvasModuleItem>(url, body);
|
||||
await axiosClient.post<CanvasModuleItem>(url, body);
|
||||
},
|
||||
|
||||
async getCourseModules(canvasCourseId: number) {
|
||||
@@ -68,7 +67,7 @@ export const canvasModuleService = {
|
||||
name: moduleName,
|
||||
},
|
||||
};
|
||||
const response = await rateLimitAwarePost<CanvasModule>(url, body);
|
||||
const response = await axiosClient.post<CanvasModule>(url, body);
|
||||
return response.data.id;
|
||||
},
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CanvasPage } from "@/features/canvas/models/pages/canvasPageModel";
|
||||
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
|
||||
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
|
||||
import { rateLimitAwareDelete, rateLimitAwarePost } from "./canvasWebRequestUtils";
|
||||
import { rateLimitAwareDelete } from "./canvasWebRequestor";
|
||||
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
||||
import { axiosClient } from "@/services/axiosUtils";
|
||||
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
|
||||
@@ -41,7 +41,7 @@ export const canvasPageService = {
|
||||
},
|
||||
};
|
||||
|
||||
const { data: canvasPage } = await rateLimitAwarePost<CanvasPage>(url, body);
|
||||
const { data: canvasPage } = await axiosClient.post<CanvasPage>(url, body);
|
||||
if (!canvasPage) {
|
||||
throw new Error("Created canvas course page was null");
|
||||
}
|
||||
|
||||
@@ -9,14 +9,11 @@ import {
|
||||
QuestionType,
|
||||
} from "@/features/local/quizzes/models/localQuizQuestion";
|
||||
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
||||
import { axiosClient } from "@/services/axiosUtils";
|
||||
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
|
||||
import { escapeMatchingText } from "@/services/utils/questionHtmlUtils";
|
||||
import {
|
||||
rateLimitAwareDelete,
|
||||
rateLimitAwarePost,
|
||||
} from "./canvasWebRequestUtils";
|
||||
|
||||
export const getAnswersForCanvas = (
|
||||
export const getAnswers = (
|
||||
question: LocalQuizQuestion,
|
||||
settings: LocalCourseSettings
|
||||
) => {
|
||||
@@ -32,36 +29,6 @@ export const getAnswersForCanvas = (
|
||||
};
|
||||
});
|
||||
|
||||
if (question.questionType === QuestionType.NUMERICAL) {
|
||||
// if (question.answers[0].numericalAnswerType === "range_answer") {
|
||||
// console.log(
|
||||
// "answer range",
|
||||
// question.answers.map((answer) => ({
|
||||
// numerical_answer_type: answer.numericalAnswerType,
|
||||
// start: answer.numericAnswerRangeMin,
|
||||
// end: answer.numericAnswerRangeMax,
|
||||
// }))
|
||||
// );
|
||||
// return question.answers.map((answer) => ({
|
||||
// numerical_answer_type: answer.numericalAnswerType,
|
||||
// start: answer.numericAnswerRangeMin + "",
|
||||
// end: answer.numericAnswerRangeMax + "",
|
||||
// }));
|
||||
// }
|
||||
return question.answers.map((answer) => {
|
||||
if (answer.numericalAnswerType === "range_answer")
|
||||
return {
|
||||
numerical_answer_type: answer.numericalAnswerType,
|
||||
answer_range_start: answer.numericAnswerRangeMin,
|
||||
answer_range_end: answer.numericAnswerRangeMax,
|
||||
};
|
||||
return {
|
||||
numerical_answer_type: answer.numericalAnswerType,
|
||||
exact: answer.numericAnswer,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return question.answers.map((answer) => ({
|
||||
answer_html: markdownToHTMLSafe({ markdownString: answer.text, settings }),
|
||||
answer_weight: answer.correct ? 100 : 0,
|
||||
@@ -69,7 +36,7 @@ export const getAnswersForCanvas = (
|
||||
}));
|
||||
};
|
||||
|
||||
export const getQuestionTypeForCanvas = (question: LocalQuizQuestion) => {
|
||||
export const getQuestionType = (question: LocalQuizQuestion) => {
|
||||
return `${question.questionType.replace("=", "")}_question`;
|
||||
};
|
||||
|
||||
@@ -84,24 +51,20 @@ const createQuestionOnly = async (
|
||||
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}/questions`;
|
||||
|
||||
console.log(question);
|
||||
const body = {
|
||||
question: {
|
||||
question_text: markdownToHTMLSafe({
|
||||
markdownString: question.text,
|
||||
settings,
|
||||
}),
|
||||
question_type: getQuestionTypeForCanvas(question),
|
||||
question_type: getQuestionType(question),
|
||||
points_possible: question.points,
|
||||
position,
|
||||
answers: getAnswersForCanvas(question, settings),
|
||||
correct_comments: question.incorrectComments,
|
||||
incorrect_comments: question.incorrectComments,
|
||||
neutral_comments: question.neutralComments,
|
||||
answers: getAnswers(question, settings),
|
||||
},
|
||||
};
|
||||
|
||||
const response = await rateLimitAwarePost<CanvasQuizQuestion>(url, body);
|
||||
const response = await axiosClient.post<CanvasQuizQuestion>(url, body);
|
||||
const newQuestion = response.data;
|
||||
|
||||
if (!newQuestion) throw new Error("Created question is null");
|
||||
@@ -123,7 +86,7 @@ const hackFixQuestionOrdering = async (
|
||||
}));
|
||||
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}/reorder`;
|
||||
await rateLimitAwarePost(url, { order });
|
||||
await axiosClient.post(url, { order });
|
||||
};
|
||||
|
||||
const verifyQuestionOrder = async (
|
||||
@@ -150,7 +113,7 @@ const verifyQuestionOrder = async (
|
||||
// Verify that questions are in the correct order by comparing text content
|
||||
// We'll use a simple approach: strip HTML tags and compare the core text content
|
||||
const stripHtml = (html: string): string => {
|
||||
return html.replace(/<[^>]*>/g, "").trim();
|
||||
return html.replace(/<[^>]*>/g, '').trim();
|
||||
};
|
||||
|
||||
for (let i = 0; i < localQuiz.questions.length; i++) {
|
||||
@@ -161,10 +124,8 @@ const verifyQuestionOrder = async (
|
||||
const canvasQuestionText = stripHtml(canvasQuestion.question_text).trim();
|
||||
|
||||
// Check if the question text content matches (allowing for HTML conversion differences)
|
||||
if (
|
||||
!canvasQuestionText.includes(localQuestionText) &&
|
||||
!localQuestionText.includes(canvasQuestionText)
|
||||
) {
|
||||
if (!canvasQuestionText.includes(localQuestionText) &&
|
||||
!localQuestionText.includes(canvasQuestionText)) {
|
||||
console.error(
|
||||
`Question order mismatch at position ${i}:`,
|
||||
`Local: "${localQuestionText}"`,
|
||||
@@ -174,14 +135,9 @@ const verifyQuestionOrder = async (
|
||||
}
|
||||
|
||||
// Verify position is correct
|
||||
if (
|
||||
canvasQuestion.position !== undefined &&
|
||||
canvasQuestion.position !== i + 1
|
||||
) {
|
||||
if (canvasQuestion.position !== undefined && canvasQuestion.position !== i + 1) {
|
||||
console.error(
|
||||
`Question position mismatch at index ${i}: Canvas position is ${
|
||||
canvasQuestion.position
|
||||
}, expected ${i + 1}`
|
||||
`Question position mismatch at index ${i}: Canvas position is ${canvasQuestion.position}, expected ${i + 1}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -338,10 +294,7 @@ export const canvasQuizService = {
|
||||
},
|
||||
};
|
||||
|
||||
const { data: canvasQuiz } = await rateLimitAwarePost<CanvasQuiz>(
|
||||
url,
|
||||
body
|
||||
);
|
||||
const { data: canvasQuiz } = await axiosClient.post<CanvasQuiz>(url, body);
|
||||
await createQuizQuestions(
|
||||
canvasCourseId,
|
||||
canvasQuiz.id,
|
||||
@@ -352,6 +305,6 @@ export const canvasQuizService = {
|
||||
},
|
||||
async delete(canvasCourseId: number, canvasQuizId: number) {
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}`;
|
||||
await rateLimitAwareDelete(url);
|
||||
await axiosClient.delete(url);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { axiosClient } from "@/services/axiosUtils";
|
||||
import { AxiosResponse, AxiosRequestConfig } from "axios";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
const rateLimitRetryCount = 6;
|
||||
const rateLimitSleepInterval = 1000;
|
||||
@@ -16,26 +16,21 @@ export const isRateLimited = async (
|
||||
);
|
||||
};
|
||||
|
||||
export const rateLimitAwarePost = async <T>(
|
||||
url: string,
|
||||
body: unknown,
|
||||
config?: AxiosRequestConfig,
|
||||
retryCount = 0
|
||||
): Promise<AxiosResponse<T>> => {
|
||||
const response = await axiosClient.post<T>(url, body, config);
|
||||
// const rateLimitAwarePost = async (url: string, body: unknown, retryCount = 0) => {
|
||||
// const response = await axiosClient.post(url, body);
|
||||
|
||||
if (await isRateLimited(response)) {
|
||||
if (retryCount < rateLimitRetryCount) {
|
||||
console.info(
|
||||
`Hit rate limit on post, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying`
|
||||
);
|
||||
await sleep(rateLimitSleepInterval);
|
||||
return await rateLimitAwarePost<T>(url, body, config, retryCount + 1);
|
||||
}
|
||||
}
|
||||
// if (await isRateLimited(response)) {
|
||||
// if (retryCount < rateLimitRetryCount) {
|
||||
// console.info(
|
||||
// `Hit rate limit on post, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying`
|
||||
// );
|
||||
// await sleep(rateLimitSleepInterval);
|
||||
// return await rateLimitAwarePost(url, body, retryCount + 1);
|
||||
// }
|
||||
// }
|
||||
|
||||
return response;
|
||||
};
|
||||
// return response;
|
||||
// };
|
||||
|
||||
export const rateLimitAwareDelete = async (
|
||||
url: string,
|
||||
@@ -4,11 +4,10 @@ import axios from "axios";
|
||||
import { canvasApi } from "../canvasServiceUtils";
|
||||
import { axiosClient } from "@/services/axiosUtils";
|
||||
import FormData from "form-data";
|
||||
import { rateLimitAwarePost } from "../canvasWebRequestUtils";
|
||||
|
||||
export const downloadUrlToTempDirectory = async (
|
||||
sourceUrl: string
|
||||
): Promise<{ fileName: string; success: boolean }> => {
|
||||
): Promise<{fileName: string, success: boolean}> => {
|
||||
try {
|
||||
const fileName =
|
||||
path.basename(new URL(sourceUrl).pathname) || `tempfile-${Date.now()}`;
|
||||
@@ -17,10 +16,10 @@ export const downloadUrlToTempDirectory = async (
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
await fs.writeFile(tempFilePath, response.data);
|
||||
return { fileName: tempFilePath, success: true };
|
||||
return {fileName: tempFilePath, success: true};
|
||||
} catch (error) {
|
||||
console.log("Error downloading or saving the file:", sourceUrl, error);
|
||||
return { fileName: sourceUrl, success: false };
|
||||
return {fileName: sourceUrl, success: false};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,10 +45,7 @@ export const uploadToCanvasPart1 = async (
|
||||
formData.append("name", path.basename(pathToUpload));
|
||||
formData.append("size", (await getFileSize(pathToUpload)).toString());
|
||||
|
||||
const response = await rateLimitAwarePost<{
|
||||
upload_url: string;
|
||||
upload_params: { [key: string]: string };
|
||||
}>(url, formData);
|
||||
const response = await axiosClient.post(url, formData);
|
||||
|
||||
const upload_url = response.data.upload_url;
|
||||
const upload_params = response.data.upload_params;
|
||||
@@ -81,14 +77,10 @@ export const uploadToCanvasPart2 = async ({
|
||||
const fileName = path.basename(pathToUpload);
|
||||
formData.append("file", fileBuffer, fileName);
|
||||
|
||||
const response = await rateLimitAwarePost<{ url: string }>(
|
||||
upload_url,
|
||||
formData,
|
||||
{
|
||||
const response = await axiosClient.post(upload_url, formData, {
|
||||
headers: formData.getHeaders(),
|
||||
validateStatus: (status) => status < 500,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (response.status === 301) {
|
||||
const redirectUrl = response.headers.location;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
|
||||
import { QuestionType } from "@/features/local/quizzes/models/localQuizQuestion";
|
||||
import { DayOfWeek } from "@/features/local/course/localCourseSettings";
|
||||
import { AssignmentSubmissionType } from "@/features/local/assignments/models/assignmentSubmissionType";
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("@/services/axiosUtils", () => ({
|
||||
axiosClient: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./canvasServiceUtils", () => ({
|
||||
canvasApi: "https://test.instructure.com/api/v1",
|
||||
paginatedRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./canvasAssignmentService", () => ({
|
||||
canvasAssignmentService: {
|
||||
getAll: vi.fn(() => Promise.resolve([])),
|
||||
delete: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/services/htmlMarkdownUtils", () => ({
|
||||
markdownToHTMLSafe: vi.fn(({ markdownString }) => `<p>${markdownString}</p>`),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/local/utils/timeUtils", () => ({
|
||||
getDateFromStringOrThrow: vi.fn((dateString) => new Date(dateString)),
|
||||
}));
|
||||
|
||||
vi.mock("@/services/utils/questionHtmlUtils", () => ({
|
||||
escapeMatchingText: vi.fn((text) => text),
|
||||
}));
|
||||
|
||||
describe("Quiz Order Verification Integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("demonstrates the question order verification workflow", async () => {
|
||||
// This test demonstrates that the verification step is properly integrated
|
||||
// into the quiz creation workflow
|
||||
|
||||
const testQuiz: LocalQuiz = {
|
||||
name: "Test Quiz - Order Verification",
|
||||
description: "Testing question order verification",
|
||||
dueAt: "2023-12-01T23:59:00Z",
|
||||
shuffleAnswers: false,
|
||||
showCorrectAnswers: true,
|
||||
oneQuestionAtATime: false,
|
||||
allowedAttempts: 1,
|
||||
questions: [
|
||||
{
|
||||
text: "First Question",
|
||||
questionType: QuestionType.SHORT_ANSWER,
|
||||
points: 5,
|
||||
answers: [],
|
||||
matchDistractors: [],
|
||||
},
|
||||
{
|
||||
text: "Second Question",
|
||||
questionType: QuestionType.ESSAY,
|
||||
points: 10,
|
||||
answers: [],
|
||||
matchDistractors: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Import the service after mocks are set up
|
||||
const { canvasQuizService } = await import("./canvasQuizService");
|
||||
const { axiosClient } = await import("@/services/axiosUtils");
|
||||
const { paginatedRequest } = await import("./canvasServiceUtils");
|
||||
|
||||
// Mock successful quiz creation
|
||||
vi.mocked(axiosClient.post).mockResolvedValueOnce({
|
||||
data: { id: 123, title: "Test Quiz - Order Verification" },
|
||||
});
|
||||
|
||||
// Mock question creation responses
|
||||
vi.mocked(axiosClient.post)
|
||||
.mockResolvedValueOnce({ data: { id: 1, position: 1 } })
|
||||
.mockResolvedValueOnce({ data: { id: 2, position: 2 } });
|
||||
|
||||
// Mock reordering call
|
||||
vi.mocked(axiosClient.post).mockResolvedValueOnce({ data: {} });
|
||||
|
||||
// Mock assignment cleanup (empty assignments)
|
||||
vi.mocked(paginatedRequest).mockResolvedValueOnce([]);
|
||||
|
||||
// Mock the verification call - questions in correct order
|
||||
vi.mocked(paginatedRequest).mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
quiz_id: 123,
|
||||
position: 1,
|
||||
question_name: "Question 1",
|
||||
question_type: "short_answer_question",
|
||||
question_text: "<p>First Question</p>",
|
||||
correct_comments: "",
|
||||
incorrect_comments: "",
|
||||
neutral_comments: "",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
quiz_id: 123,
|
||||
position: 2,
|
||||
question_name: "Question 2",
|
||||
question_type: "essay_question",
|
||||
question_text: "<p>Second Question</p>",
|
||||
correct_comments: "",
|
||||
incorrect_comments: "",
|
||||
neutral_comments: "",
|
||||
},
|
||||
]);
|
||||
|
||||
// Create the quiz and trigger verification
|
||||
const result = await canvasQuizService.create(12345, testQuiz, {
|
||||
name: "Test Course",
|
||||
canvasId: 12345,
|
||||
assignmentGroups: [],
|
||||
daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Wednesday, DayOfWeek.Friday],
|
||||
startDate: "2023-08-15",
|
||||
endDate: "2023-12-15",
|
||||
defaultDueTime: { hour: 23, minute: 59 },
|
||||
defaultAssignmentSubmissionTypes: [AssignmentSubmissionType.ONLINE_TEXT_ENTRY],
|
||||
defaultFileUploadTypes: [],
|
||||
holidays: [],
|
||||
assets: []
|
||||
});
|
||||
|
||||
// Verify the quiz was created
|
||||
expect(result).toBe(123);
|
||||
|
||||
// Verify that the question verification API call was made
|
||||
expect(vi.mocked(paginatedRequest)).toHaveBeenCalledWith({
|
||||
url: "https://test.instructure.com/api/v1/courses/12345/quizzes/123/questions",
|
||||
});
|
||||
|
||||
// The verification would have run and logged success/failure
|
||||
// In a real scenario, this would catch order mismatches
|
||||
});
|
||||
|
||||
it("demonstrates successful verification workflow", async () => {
|
||||
const { canvasQuizService } = await import("./canvasQuizService");
|
||||
const { paginatedRequest } = await import("./canvasServiceUtils");
|
||||
|
||||
// Mock questions returned from Canvas in correct order
|
||||
vi.mocked(paginatedRequest).mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
quiz_id: 1,
|
||||
position: 1,
|
||||
question_name: "Question 1",
|
||||
question_type: "short_answer_question",
|
||||
question_text: "First question",
|
||||
correct_comments: "",
|
||||
incorrect_comments: "",
|
||||
neutral_comments: "",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
quiz_id: 1,
|
||||
position: 2,
|
||||
question_name: "Question 2",
|
||||
question_type: "essay_question",
|
||||
question_text: "Second question",
|
||||
correct_comments: "",
|
||||
incorrect_comments: "",
|
||||
neutral_comments: "",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await canvasQuizService.getQuizQuestions(1, 1);
|
||||
|
||||
// Verify questions are returned in correct order
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].position).toBe(1);
|
||||
expect(result[1].position).toBe(2);
|
||||
expect(result[0].question_text).toBe("First question");
|
||||
expect(result[1].question_text).toBe("Second question");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,6 @@ import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorage
|
||||
import { promises as fs } from "fs";
|
||||
import { courseItemFileStorageService } from "../course/courseItemFileStorageService";
|
||||
import { assignmentMarkdownSerializer } from "./models/utils/assignmentMarkdownSerializer";
|
||||
import { assertValidFileName } from "@/services/fileNameValidation";
|
||||
|
||||
export const assignmentRouter = router({
|
||||
getAssignment: publicProcedure
|
||||
@@ -134,8 +133,6 @@ export async function updateOrCreateAssignmentFile({
|
||||
assignmentName: string;
|
||||
assignment: LocalAssignment;
|
||||
}) {
|
||||
assertValidFileName(assignmentName);
|
||||
|
||||
const courseDirectory = await getCoursePathByName(courseName);
|
||||
const folder = path.join(courseDirectory, moduleName, "assignments");
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
|
||||
@@ -9,16 +9,13 @@ import {
|
||||
CourseItemType,
|
||||
typeToFolder,
|
||||
} from "@/features/local/course/courseItemTypes";
|
||||
import {
|
||||
getCoursePathByName,
|
||||
getGlobalSettings,
|
||||
} from "../globalSettings/globalSettingsFileStorageService";
|
||||
import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService";
|
||||
import {
|
||||
localPageMarkdownUtils,
|
||||
} from "@/features/local/pages/localCoursePageModels";
|
||||
import { quizMarkdownUtils } from "../quizzes/models/utils/quizMarkdownUtils";
|
||||
import { getFeedbackDelimitersFromSettings } from "../globalSettings/globalSettingsUtils";
|
||||
|
||||
import {
|
||||
localQuizMarkdownUtils,
|
||||
} from "@/features/local/quizzes/models/localQuiz";
|
||||
|
||||
const getItemFileNames = async ({
|
||||
courseName,
|
||||
@@ -64,12 +61,9 @@ const getItem = async <T extends CourseItemType>({
|
||||
name
|
||||
) as CourseItemReturnType<T>;
|
||||
} else if (type === "Quiz") {
|
||||
const globalSettings = await getGlobalSettings();
|
||||
const delimiters = getFeedbackDelimitersFromSettings(globalSettings);
|
||||
return quizMarkdownUtils.parseMarkdown(
|
||||
return localQuizMarkdownUtils.parseMarkdown(
|
||||
rawFile,
|
||||
name,
|
||||
delimiters
|
||||
name
|
||||
) as CourseItemReturnType<T>;
|
||||
} else if (type === "Page") {
|
||||
return localPageMarkdownUtils.parseMarkdown(
|
||||
@@ -99,8 +93,7 @@ export const courseItemFileStorageService = {
|
||||
try {
|
||||
const item = await getItem({ courseName, moduleName, name, type });
|
||||
return item;
|
||||
} catch (e) {
|
||||
console.log(`Error loading ${type} ${name} in module ${moduleName}:`, e);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
|
||||
@@ -103,13 +103,6 @@ async function migrateCourseContent(
|
||||
) {
|
||||
const oldCourseName = settingsFromCourseToImport.name;
|
||||
const newCourseName = settings.name;
|
||||
console.log(
|
||||
"migrating content from ",
|
||||
oldCourseName,
|
||||
"to ",
|
||||
newCourseName
|
||||
);
|
||||
|
||||
const oldModules = await getModuleNamesFromFiles(oldCourseName);
|
||||
await Promise.all(
|
||||
oldModules.map(async (moduleName) => {
|
||||
|
||||
@@ -7,7 +7,6 @@ export const zodGlobalSettingsCourse = z.object({
|
||||
|
||||
export const zodGlobalSettings = z.object({
|
||||
courses: z.array(zodGlobalSettingsCourse),
|
||||
feedbackDelims: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getFeedbackDelimitersFromSettings, overriddenDefaults } from "./globalSettingsUtils";
|
||||
import { defaultFeedbackDelimiters } from "../quizzes/models/utils/quizFeedbackMarkdownUtils";
|
||||
import { GlobalSettings } from "./globalSettingsModels";
|
||||
|
||||
describe("overriddenDefaults", () => {
|
||||
it("uses defaults when overrides are missing", () => {
|
||||
const defaults = { a: 1, b: 2 };
|
||||
const overrides = {};
|
||||
expect(overriddenDefaults(defaults, overrides)).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it("uses overrides when present", () => {
|
||||
const defaults = { a: 1, b: 2 };
|
||||
const overrides = { a: 3 };
|
||||
expect(overriddenDefaults(defaults, overrides)).toEqual({ a: 3, b: 2 });
|
||||
});
|
||||
|
||||
it("ignores extra keys in overrides", () => {
|
||||
const defaults = { a: 1 };
|
||||
const overrides = { a: 2, c: 3 };
|
||||
expect(overriddenDefaults(defaults, overrides)).toEqual({ a: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeedbackDelimitersFromSettings", () => {
|
||||
it("returns default delimiters if options are missing", () => {
|
||||
const settings: GlobalSettings = {
|
||||
courses: [],
|
||||
};
|
||||
expect(getFeedbackDelimitersFromSettings(settings)).toEqual(
|
||||
defaultFeedbackDelimiters
|
||||
);
|
||||
});
|
||||
|
||||
it("returns custom delimiters if options are present", () => {
|
||||
const settings: GlobalSettings = {
|
||||
courses: [],
|
||||
feedbackDelims: {
|
||||
neutral: ":|",
|
||||
correct: ":)",
|
||||
incorrect: ":(",
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
correct: ":)",
|
||||
incorrect: ":(",
|
||||
neutral: ":|",
|
||||
};
|
||||
expect(getFeedbackDelimitersFromSettings(settings)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns mixed delimiters if some options are missing", () => {
|
||||
const settings: GlobalSettings = {
|
||||
courses: [],
|
||||
feedbackDelims: {
|
||||
correct: ":)",
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
...defaultFeedbackDelimiters,
|
||||
correct: ":)",
|
||||
};
|
||||
expect(getFeedbackDelimitersFromSettings(settings)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,5 @@
|
||||
import { GlobalSettings, zodGlobalSettings } from "./globalSettingsModels";
|
||||
import { parse, stringify } from "yaml";
|
||||
import {
|
||||
FeedbackDelimiters,
|
||||
defaultFeedbackDelimiters,
|
||||
} from "../quizzes/models/utils/quizFeedbackMarkdownUtils";
|
||||
|
||||
export const globalSettingsToYaml = (settings: GlobalSettings) => {
|
||||
return stringify(settings);
|
||||
@@ -18,22 +14,3 @@ export const parseGlobalSettingsYaml = (yaml: string): GlobalSettings => {
|
||||
throw new Error(`Error parsing global settings, got ${yaml}, ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
export function overriddenDefaults<T>(
|
||||
defaults: T,
|
||||
overrides: Record<string, unknown>
|
||||
): T {
|
||||
return Object.fromEntries(
|
||||
Object.entries(defaults as Record<string, unknown>).map(([k, v]) => [k, overrides[k] ?? v])
|
||||
) as T;
|
||||
}
|
||||
|
||||
export const getFeedbackDelimitersFromSettings = (
|
||||
settings: GlobalSettings
|
||||
): FeedbackDelimiters => {
|
||||
return overriddenDefaults(
|
||||
defaultFeedbackDelimiters,
|
||||
settings.feedbackDelims ?? ({} as Record<string, unknown>)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import publicProcedure from "../../../services/serverFunctions/publicProcedure";
|
||||
import { z } from "zod";
|
||||
import { router } from "../../../services/serverFunctions/trpcSetup";
|
||||
import {
|
||||
LocalCoursePage,
|
||||
localPageMarkdownUtils,
|
||||
zodLocalCoursePage,
|
||||
} from "@/features/local/pages/localCoursePageModels";
|
||||
import { LocalCoursePage, localPageMarkdownUtils, zodLocalCoursePage } from "@/features/local/pages/localCoursePageModels";
|
||||
import { courseItemFileStorageService } from "../course/courseItemFileStorageService";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService";
|
||||
import { assertValidFileName } from "@/services/fileNameValidation";
|
||||
|
||||
export const pageRouter = router({
|
||||
getPage: publicProcedure
|
||||
@@ -124,13 +119,12 @@ export async function updatePageFile({
|
||||
moduleName,
|
||||
pageName,
|
||||
page,
|
||||
}: {
|
||||
}: {
|
||||
courseName: string;
|
||||
moduleName: string;
|
||||
pageName: string;
|
||||
page: LocalCoursePage;
|
||||
}) {
|
||||
assertValidFileName(pageName);
|
||||
}) {
|
||||
const courseDirectory = await getCoursePathByName(courseName);
|
||||
const folder = path.join(courseDirectory, moduleName, "pages");
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
@@ -145,7 +139,7 @@ export async function updatePageFile({
|
||||
const pageMarkdown = localPageMarkdownUtils.toMarkdown(page);
|
||||
console.log(`Saving page ${filePath}`);
|
||||
await fs.writeFile(filePath, pageMarkdown);
|
||||
}
|
||||
}
|
||||
async function deletePageFile({
|
||||
courseName,
|
||||
moduleName,
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { quizQuestionMarkdownUtils } from "../../quizzes/models/utils/quizQuestionMarkdownUtils";
|
||||
import { FeedbackDelimiters } from "../../quizzes/models/utils/quizFeedbackMarkdownUtils";
|
||||
import { QuestionType } from "../../quizzes/models/localQuizQuestion";
|
||||
|
||||
describe("Custom Feedback Delimiters", () => {
|
||||
const customDelimiters: FeedbackDelimiters = {
|
||||
correct: ":)",
|
||||
incorrect: ":(",
|
||||
neutral: ":|",
|
||||
};
|
||||
|
||||
it("can parse question with custom feedback delimiters", () => {
|
||||
const input = `Points: 1
|
||||
Question text
|
||||
:) Correct feedback
|
||||
:( Incorrect feedback
|
||||
:| Neutral feedback
|
||||
*a) Answer 1
|
||||
b) Answer 2`;
|
||||
|
||||
const question = quizQuestionMarkdownUtils.parseMarkdown(input, 0, customDelimiters);
|
||||
|
||||
expect(question.correctComments).toBe("Correct feedback");
|
||||
expect(question.incorrectComments).toBe("Incorrect feedback");
|
||||
expect(question.neutralComments).toBe("Neutral feedback");
|
||||
});
|
||||
|
||||
it("can serialize question with custom feedback delimiters", () => {
|
||||
const question = {
|
||||
points: 1,
|
||||
text: "Question text",
|
||||
questionType: "multiple_choice_question" as QuestionType,
|
||||
answers: [
|
||||
{ text: "Answer 1", correct: true, weight: 100 },
|
||||
{ text: "Answer 2", correct: false, weight: 0 },
|
||||
],
|
||||
correctComments: "Correct feedback",
|
||||
incorrectComments: "Incorrect feedback",
|
||||
neutralComments: "Neutral feedback",
|
||||
matchDistractors: [],
|
||||
};
|
||||
|
||||
const markdown = quizQuestionMarkdownUtils.toMarkdown(question, customDelimiters);
|
||||
|
||||
expect(markdown).toContain(":) Correct feedback");
|
||||
expect(markdown).toContain(":( Incorrect feedback");
|
||||
expect(markdown).toContain(":| Neutral feedback");
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { quizQuestionMarkdownUtils } from "@/features/local/quizzes/models/utils/quizQuestionMarkdownUtils";
|
||||
import { QuestionType, LocalQuizQuestion } from "@/features/local/quizzes/models/localQuizQuestion";
|
||||
|
||||
describe("feedback spacing", () => {
|
||||
it("adds a blank line after feedback before answers", () => {
|
||||
const question = {
|
||||
text: "What is 2+2?",
|
||||
questionType: QuestionType.MULTIPLE_CHOICE,
|
||||
points: 1,
|
||||
answers: [
|
||||
{ correct: true, text: "4" },
|
||||
],
|
||||
matchDistractors: [],
|
||||
correctComments: "Good",
|
||||
incorrectComments: "No",
|
||||
neutralComments: "Note",
|
||||
} as LocalQuizQuestion;
|
||||
|
||||
const md = quizQuestionMarkdownUtils.toMarkdown(question);
|
||||
|
||||
// look for double newline separating feedback block and answer marker
|
||||
expect(md).toMatch(/\n\n\*?a\)/);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { quizMarkdownUtils } from "../../quizzes/models/utils/quizMarkdownUtils";
|
||||
import { QuestionType } from "../../quizzes/models/localQuizQuestion";
|
||||
|
||||
describe("numerical answer questions", () => {
|
||||
it("can parse question with numerical answers", () => {
|
||||
const name = "Test Quiz";
|
||||
const rawMarkdownQuiz = `
|
||||
ShuffleAnswers: true
|
||||
OneQuestionAtATime: false
|
||||
DueAt: 08/21/2023 23:59:00
|
||||
LockAt: 08/21/2023 23:59:00
|
||||
AssignmentGroup: Assignments
|
||||
AllowedAttempts: -1
|
||||
Description: quiz description
|
||||
---
|
||||
What is 2+3?
|
||||
= 5
|
||||
`;
|
||||
|
||||
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||
const question = quiz.questions[0];
|
||||
|
||||
expect(question.text).toBe("What is 2+3?");
|
||||
expect(question.questionType).toBe(QuestionType.NUMERICAL);
|
||||
expect(question.answers[0].numericAnswer).toBe(5);
|
||||
});
|
||||
it("can parse question with range answers", () => {
|
||||
const name = "Test Quiz";
|
||||
const rawMarkdownQuiz = `
|
||||
ShuffleAnswers: true
|
||||
OneQuestionAtATime: false
|
||||
DueAt: 08/21/2023 23:59:00
|
||||
LockAt: 08/21/2023 23:59:00
|
||||
AssignmentGroup: Assignments
|
||||
AllowedAttempts: -1
|
||||
Description: quiz description
|
||||
---
|
||||
What is the cube root of 2?
|
||||
= [1.2598, 1.2600]
|
||||
`;
|
||||
|
||||
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||
const question = quiz.questions[0];
|
||||
|
||||
expect(question.text).toBe("What is the cube root of 2?");
|
||||
expect(question.questionType).toBe(QuestionType.NUMERICAL);
|
||||
expect(question.answers[0].numericalAnswerType).toBe("range_answer");
|
||||
expect(question.answers[0].numericAnswerRangeMin).toBe(1.2598);
|
||||
expect(question.answers[0].numericAnswerRangeMax).toBe(1.26);
|
||||
});
|
||||
});
|
||||
@@ -1,324 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { QuestionType } from "@/features/local/quizzes/models/localQuizQuestion";
|
||||
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
|
||||
import { LocalQuiz } from "../../quizzes/models/localQuiz";
|
||||
|
||||
describe("Question Feedback options", () => {
|
||||
it("can parse question with correct feedback", () => {
|
||||
const name = "Test Quiz";
|
||||
const rawMarkdownQuiz = `
|
||||
ShuffleAnswers: true
|
||||
OneQuestionAtATime: false
|
||||
DueAt: 08/21/2023 23:59:00
|
||||
LockAt: 08/21/2023 23:59:00
|
||||
AssignmentGroup: Assignments
|
||||
AllowedAttempts: -1
|
||||
Description: quiz description
|
||||
---
|
||||
Points: 3
|
||||
What is the purpose of a context switch?
|
||||
+ Correct! The context switch is used to change the current process by swapping the registers and other state with a new process
|
||||
*a) To change the current window you are on
|
||||
b) To change the current process's status
|
||||
*c) To swap the current process's registers for a new process's registers
|
||||
`;
|
||||
|
||||
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||
const question = quiz.questions[0];
|
||||
|
||||
expect(question.correctComments).toBe(
|
||||
"Correct! The context switch is used to change the current process by swapping the registers and other state with a new process"
|
||||
);
|
||||
expect(question.incorrectComments).toBeUndefined();
|
||||
expect(question.neutralComments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can parse question with incorrect feedback", () => {
|
||||
const name = "Test Quiz";
|
||||
const rawMarkdownQuiz = `
|
||||
ShuffleAnswers: true
|
||||
OneQuestionAtATime: false
|
||||
DueAt: 08/21/2023 23:59:00
|
||||
LockAt: 08/21/2023 23:59:00
|
||||
AssignmentGroup: Assignments
|
||||
AllowedAttempts: -1
|
||||
Description: quiz description
|
||||
---
|
||||
Points: 3
|
||||
What state does a process need to be in to be able to be scheduled?
|
||||
- Incorrect! A process in ready state can be scheduled
|
||||
*a) Ready
|
||||
b) Running
|
||||
c) Zombie
|
||||
d) Embryo
|
||||
`;
|
||||
|
||||
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||
const question = quiz.questions[0];
|
||||
|
||||
expect(question.incorrectComments).toBe(
|
||||
"Incorrect! A process in ready state can be scheduled"
|
||||
);
|
||||
expect(question.correctComments).toBeUndefined();
|
||||
expect(question.neutralComments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can parse question with correct and incorrect feedback", () => {
|
||||
const name = "Test Quiz";
|
||||
const rawMarkdownQuiz = `
|
||||
ShuffleAnswers: true
|
||||
OneQuestionAtATime: false
|
||||
DueAt: 08/21/2023 23:59:00
|
||||
LockAt: 08/21/2023 23:59:00
|
||||
AssignmentGroup: Assignments
|
||||
AllowedAttempts: -1
|
||||
Description: quiz description
|
||||
---
|
||||
Points: 3
|
||||
What is the purpose of a context switch?
|
||||
+ Correct! The context switch is used to change the current process
|
||||
- Incorrect! The context switch is NOT used to change windows
|
||||
*a) To change the current window you are on
|
||||
b) To change the current process's status
|
||||
*c) To swap the current process's registers for a new process's registers
|
||||
`;
|
||||
|
||||
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||
const question = quiz.questions[0];
|
||||
|
||||
expect(question.correctComments).toBe(
|
||||
"Correct! The context switch is used to change the current process"
|
||||
);
|
||||
expect(question.incorrectComments).toBe(
|
||||
"Incorrect! The context switch is NOT used to change windows"
|
||||
);
|
||||
expect(question.neutralComments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can parse question with neutral feedback", () => {
|
||||
const name = "Test Quiz";
|
||||
const rawMarkdownQuiz = `
|
||||
ShuffleAnswers: true
|
||||
OneQuestionAtATime: false
|
||||
DueAt: 08/21/2023 23:59:00
|
||||
LockAt: 08/21/2023 23:59:00
|
||||
AssignmentGroup: Assignments
|
||||
AllowedAttempts: -1
|
||||
Description: quiz description
|
||||
---
|
||||
Points: 3
|
||||
What is a prime number?
|
||||
... This feedback will be shown regardless of the answer
|
||||
*a) A number divisible only by 1 and itself
|
||||
b) Any odd number
|
||||
c) Any even number
|
||||
`;
|
||||
|
||||
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||
const question = quiz.questions[0];
|
||||
|
||||
expect(question.neutralComments).toBe(
|
||||
"This feedback will be shown regardless of the answer"
|
||||
);
|
||||
expect(question.correctComments).toBeUndefined();
|
||||
expect(question.incorrectComments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can parse question with all three feedback types", () => {
|
||||
const name = "Test Quiz";
|
||||
const rawMarkdownQuiz = `
|
||||
ShuffleAnswers: true
|
||||
OneQuestionAtATime: false
|
||||
DueAt: 08/21/2023 23:59:00
|
||||
LockAt: 08/21/2023 23:59:00
|
||||
AssignmentGroup: Assignments
|
||||
AllowedAttempts: -1
|
||||
Description: quiz description
|
||||
---
|
||||
Points: 3
|
||||
What is the purpose of a context switch?
|
||||
+ Great job! You understand context switching
|
||||
- Try reviewing the material on process management
|
||||
... Context switches are a fundamental operating system concept
|
||||
*a) To change the current window you are on
|
||||
b) To change the current process's status
|
||||
*c) To swap the current process's registers for a new process's registers
|
||||
`;
|
||||
|
||||
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||
const question = quiz.questions[0];
|
||||
|
||||
expect(question.correctComments).toBe(
|
||||
"Great job! You understand context switching"
|
||||
);
|
||||
expect(question.incorrectComments).toBe(
|
||||
"Try reviewing the material on process management"
|
||||
);
|
||||
expect(question.neutralComments).toBe(
|
||||
"Context switches are a fundamental operating system concept"
|
||||
);
|
||||
});
|
||||
|
||||
it("can parse multiline feedback", () => {
|
||||
const name = "Test Quiz";
|
||||
const rawMarkdownQuiz = `
|
||||
ShuffleAnswers: true
|
||||
OneQuestionAtATime: false
|
||||
DueAt: 08/21/2023 23:59:00
|
||||
LockAt: 08/21/2023 23:59:00
|
||||
AssignmentGroup: Assignments
|
||||
AllowedAttempts: -1
|
||||
Description: quiz description
|
||||
---
|
||||
Points: 3
|
||||
What is the purpose of a context switch?
|
||||
+ Correct! The context switch is used to change the current process.
|
||||
This is additional information on a new line.
|
||||
- Incorrect! You should review the material.
|
||||
Check your notes on process management.
|
||||
*a) To change the current window you are on
|
||||
b) To change the current process's status
|
||||
*c) To swap the current process's registers for a new process's registers
|
||||
`;
|
||||
|
||||
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||
const question = quiz.questions[0];
|
||||
|
||||
expect(question.correctComments).toBe(
|
||||
"Correct! The context switch is used to change the current process.\nThis is additional information on a new line."
|
||||
);
|
||||
expect(question.incorrectComments).toBe(
|
||||
"Incorrect! You should review the material.\nCheck your notes on process management."
|
||||
);
|
||||
});
|
||||
|
||||
it("feedback can serialize to markdown", () => {
|
||||
const quiz: LocalQuiz = {
|
||||
name: "Test Quiz",
|
||||
description: "quiz description",
|
||||
lockAt: new Date(8640000000000000).toISOString(),
|
||||
dueAt: new Date(8640000000000000).toISOString(),
|
||||
shuffleAnswers: true,
|
||||
oneQuestionAtATime: false,
|
||||
localAssignmentGroupName: "Assignments",
|
||||
allowedAttempts: -1,
|
||||
showCorrectAnswers: false,
|
||||
questions: [
|
||||
{
|
||||
text: "What is the purpose of a context switch?",
|
||||
questionType: QuestionType.MULTIPLE_CHOICE,
|
||||
points: 3,
|
||||
correctComments: "Correct! Good job",
|
||||
incorrectComments: "Incorrect! Try again",
|
||||
neutralComments: "Context switches are important",
|
||||
answers: [
|
||||
{ correct: false, text: "To change the current window you are on" },
|
||||
{ correct: true, text: "To swap registers" },
|
||||
],
|
||||
matchDistractors: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const markdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||
|
||||
expect(markdown).toContain("+ Correct! Good job");
|
||||
expect(markdown).toContain("- Incorrect! Try again");
|
||||
expect(markdown).toContain("... Context switches are important");
|
||||
});
|
||||
|
||||
it("can parse question with alternative format using ellipsis for general feedback", () => {
|
||||
const name = "Test Quiz";
|
||||
const rawMarkdownQuiz = `
|
||||
ShuffleAnswers: true
|
||||
OneQuestionAtATime: false
|
||||
DueAt: 08/21/2023 23:59:00
|
||||
LockAt: 08/21/2023 23:59:00
|
||||
AssignmentGroup: Assignments
|
||||
AllowedAttempts: -1
|
||||
Description: An addition question
|
||||
---
|
||||
Points: 2
|
||||
What is 2+3?
|
||||
... General question feedback.
|
||||
+ Feedback for correct answer.
|
||||
- Feedback for incorrect answer.
|
||||
a) 6
|
||||
b) 1
|
||||
*c) 5
|
||||
`;
|
||||
|
||||
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||
const question = quiz.questions[0];
|
||||
|
||||
expect(question.text).toBe("What is 2+3?");
|
||||
expect(question.points).toBe(2);
|
||||
expect(question.neutralComments).toBe("General question feedback.");
|
||||
expect(question.correctComments).toBe("Feedback for correct answer.");
|
||||
expect(question.incorrectComments).toBe("Feedback for incorrect answer.");
|
||||
expect(question.answers).toHaveLength(3);
|
||||
expect(question.answers[0].text).toBe("6");
|
||||
expect(question.answers[0].correct).toBe(false);
|
||||
expect(question.answers[1].text).toBe("1");
|
||||
expect(question.answers[1].correct).toBe(false);
|
||||
expect(question.answers[2].text).toBe("5");
|
||||
expect(question.answers[2].correct).toBe(true);
|
||||
});
|
||||
|
||||
it("can parse multiline general feedback with ellipsis", () => {
|
||||
const name = "Test Quiz";
|
||||
const rawMarkdownQuiz = `
|
||||
ShuffleAnswers: true
|
||||
OneQuestionAtATime: false
|
||||
DueAt: 08/21/2023 23:59:00
|
||||
LockAt: 08/21/2023 23:59:00
|
||||
AssignmentGroup: Assignments
|
||||
AllowedAttempts: -1
|
||||
Description: quiz description
|
||||
---
|
||||
Points: 2
|
||||
What is 2+3?
|
||||
...
|
||||
General question feedback.
|
||||
This continues on multiple lines.
|
||||
+ Feedback for correct answer.
|
||||
- Feedback for incorrect answer.
|
||||
a) 6
|
||||
b) 1
|
||||
*c) 5
|
||||
`;
|
||||
|
||||
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||
const question = quiz.questions[0];
|
||||
|
||||
expect(question.neutralComments).toBe(
|
||||
"General question feedback.\nThis continues on multiple lines."
|
||||
);
|
||||
expect(question.correctComments).toBe("Feedback for correct answer.");
|
||||
expect(question.incorrectComments).toBe("Feedback for incorrect answer.");
|
||||
});
|
||||
it("essay questions can have feedback", () => {
|
||||
const name = "Test Quiz";
|
||||
const rawMarkdownQuiz = `
|
||||
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 description
|
||||
... this is general feedback
|
||||
essay
|
||||
`;
|
||||
|
||||
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||
const firstQuestion = quiz.questions[0];
|
||||
|
||||
expect(firstQuestion.questionType).toBe(QuestionType.ESSAY);
|
||||
expect(firstQuestion.text).not.toContain("this is general feedback");
|
||||
expect(firstQuestion.neutralComments).toBe("this is general feedback");
|
||||
expect(firstQuestion.neutralComments).not.toContain("...");
|
||||
});
|
||||
});
|
||||
@@ -200,79 +200,6 @@ describe("QuizDeterministicChecks", () => {
|
||||
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown, name);
|
||||
|
||||
expect(parsedQuiz).toEqual(quiz);
|
||||
});
|
||||
it("SerializationIsDeterministic Numeric with exact answer", () => {
|
||||
const name = "Test Quiz";
|
||||
const quiz: LocalQuiz = {
|
||||
name,
|
||||
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 numeric",
|
||||
questionType: QuestionType.NUMERICAL,
|
||||
points: 1,
|
||||
matchDistractors: [],
|
||||
answers: [
|
||||
{
|
||||
text: "= 42",
|
||||
correct: true,
|
||||
numericalAnswerType: "exact_answer",
|
||||
numericAnswer: 42,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
allowedAttempts: -1,
|
||||
showCorrectAnswers: true,
|
||||
};
|
||||
|
||||
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown, name);
|
||||
|
||||
expect(parsedQuiz).toEqual(quiz);
|
||||
});
|
||||
it("SerializationIsDeterministic Numeric with range answer", () => {
|
||||
const name = "Test Quiz";
|
||||
const quiz: LocalQuiz = {
|
||||
name,
|
||||
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 numeric",
|
||||
questionType: QuestionType.NUMERICAL,
|
||||
points: 1,
|
||||
matchDistractors: [],
|
||||
answers: [
|
||||
{
|
||||
text: "= [2, 5]",
|
||||
correct: true,
|
||||
numericalAnswerType: "range_answer",
|
||||
numericAnswerRangeMin: 2,
|
||||
numericAnswerRangeMax: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
allowedAttempts: -1,
|
||||
showCorrectAnswers: true,
|
||||
};
|
||||
|
||||
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown, name);
|
||||
|
||||
expect(parsedQuiz).toEqual(quiz);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { QuestionType } from "@/features/local/quizzes/models/localQuizQuestion"
|
||||
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
|
||||
import { quizQuestionMarkdownUtils } from "@/features/local/quizzes/models/utils/quizQuestionMarkdownUtils";
|
||||
|
||||
// Test suite for QuizMarkdown
|
||||
describe("QuizMarkdownTests", () => {
|
||||
it("can serialize quiz to markdown", () => {
|
||||
const quiz: LocalQuiz = {
|
||||
@@ -280,5 +281,4 @@ b) false
|
||||
expect(quizHtml).toContain("<mi>x</mi>");
|
||||
expect(quizHtml).not.toContain("x_2");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {
|
||||
getQuestionTypeForCanvas,
|
||||
getAnswersForCanvas,
|
||||
} from "@/features/canvas/services/canvasQuizService";
|
||||
import { getQuestionType, getAnswers } from "@/features/canvas/services/canvasQuizService";
|
||||
import {
|
||||
QuestionType,
|
||||
zodQuestionType,
|
||||
} from "@/features/local/quizzes/models/localQuizQuestion";
|
||||
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
|
||||
import { quizQuestionMarkdownUtils } from "@/features/local/quizzes/models/utils/quizQuestionMarkdownUtils";
|
||||
@@ -207,6 +205,12 @@ short_answer=
|
||||
expect(firstQuestion.answers[1].text).toBe("other");
|
||||
});
|
||||
|
||||
it("Has short_answer= type at the same position in types and zod types", () => {
|
||||
expect(Object.values(zodQuestionType.Enum)).toEqual(
|
||||
Object.values(QuestionType)
|
||||
);
|
||||
});
|
||||
|
||||
it("Associates short_answer= questions with short_answer_question canvas question type", () => {
|
||||
const name = "Test Quiz";
|
||||
const rawMarkdownQuiz = `
|
||||
@@ -228,9 +232,7 @@ short_answer=
|
||||
|
||||
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||
const firstQuestion = quiz.questions[0];
|
||||
expect(getQuestionTypeForCanvas(firstQuestion)).toBe(
|
||||
"short_answer_question"
|
||||
);
|
||||
expect(getQuestionType(firstQuestion)).toBe("short_answer_question");
|
||||
});
|
||||
|
||||
it("Includes answer_text in answers sent to canvas", () => {
|
||||
@@ -254,7 +256,7 @@ short_answer=
|
||||
|
||||
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||
const firstQuestion = quiz.questions[0];
|
||||
const answers = getAnswersForCanvas(firstQuestion, {
|
||||
const answers = getAnswers(firstQuestion, {
|
||||
name: "",
|
||||
assignmentGroups: [],
|
||||
daysOfWeek: [],
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { z } from "zod";
|
||||
import { zodLocalQuizQuestion } from "./localQuizQuestion";
|
||||
import { LocalQuizQuestion, zodLocalQuizQuestion } from "./localQuizQuestion";
|
||||
import { quizMarkdownUtils } from "./utils/quizMarkdownUtils";
|
||||
import { IModuleItem } from "@/features/local/modules/IModuleItem";
|
||||
|
||||
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(),
|
||||
@@ -16,4 +31,7 @@ export const zodLocalQuiz = z.object({
|
||||
questions: zodLocalQuizQuestion.array(),
|
||||
});
|
||||
|
||||
export interface LocalQuiz extends IModuleItem, z.infer<typeof zodLocalQuiz> {}
|
||||
export const localQuizMarkdownUtils = {
|
||||
parseMarkdown: quizMarkdownUtils.parseMarkdown,
|
||||
toMarkdown: quizMarkdownUtils.toMarkdown,
|
||||
};
|
||||
|
||||
@@ -1,38 +1,40 @@
|
||||
import { z } from "zod";
|
||||
import { zodLocalQuizQuestionAnswer } from "./localQuizQuestionAnswer";
|
||||
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 = "",
|
||||
SHORT_ANSWER_WITH_ANSWERS = "short_answer=",
|
||||
}
|
||||
|
||||
export const zodQuestionType = z.enum([
|
||||
"multiple_answers",
|
||||
"multiple_choice",
|
||||
"essay",
|
||||
"short_answer",
|
||||
"matching",
|
||||
"",
|
||||
"short_answer=",
|
||||
"numerical",
|
||||
QuestionType.MULTIPLE_ANSWERS,
|
||||
QuestionType.MULTIPLE_CHOICE,
|
||||
QuestionType.ESSAY,
|
||||
QuestionType.SHORT_ANSWER,
|
||||
QuestionType.MATCHING,
|
||||
QuestionType.NONE,
|
||||
QuestionType.SHORT_ANSWER_WITH_ANSWERS,
|
||||
]);
|
||||
|
||||
export const QuestionType = {
|
||||
MULTIPLE_ANSWERS: "multiple_answers",
|
||||
MULTIPLE_CHOICE: "multiple_choice",
|
||||
ESSAY: "essay",
|
||||
SHORT_ANSWER: "short_answer",
|
||||
MATCHING: "matching",
|
||||
NONE: "",
|
||||
SHORT_ANSWER_WITH_ANSWERS: "short_answer=",
|
||||
NUMERICAL: "numerical",
|
||||
} as const;
|
||||
|
||||
export type QuestionType = z.infer<typeof zodQuestionType>;
|
||||
|
||||
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()),
|
||||
correctComments: z.string().optional(),
|
||||
incorrectComments: z.string().optional(),
|
||||
neutralComments: z.string().optional(),
|
||||
});
|
||||
export type LocalQuizQuestion = z.infer<typeof zodLocalQuizQuestion>;
|
||||
|
||||
@@ -1,18 +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(),
|
||||
numericalAnswerType: z
|
||||
.enum(["exact_answer", "range_answer", "precision_answer"])
|
||||
.optional(),
|
||||
numericAnswer: z.number().optional(),
|
||||
numericAnswerRangeMin: z.number().optional(),
|
||||
numericAnswerRangeMax: z.number().optional(),
|
||||
numericAnswerMargin: z.number().optional(),
|
||||
});
|
||||
|
||||
export type LocalQuizQuestionAnswer = z.infer<
|
||||
typeof zodLocalQuizQuestionAnswer
|
||||
>;
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
export interface FeedbackDelimiters {
|
||||
correct: string;
|
||||
incorrect: string;
|
||||
neutral: string;
|
||||
}
|
||||
|
||||
export const defaultFeedbackDelimiters: FeedbackDelimiters = {
|
||||
correct: "+",
|
||||
incorrect: "-",
|
||||
neutral: "...",
|
||||
};
|
||||
|
||||
type feedbackTypeOptions = "correct" | "incorrect" | "neutral" | "none";
|
||||
|
||||
export const quizFeedbackMarkdownUtils = {
|
||||
extractFeedback(
|
||||
lines: string[],
|
||||
delimiters: FeedbackDelimiters = defaultFeedbackDelimiters
|
||||
): {
|
||||
correctComments?: string;
|
||||
incorrectComments?: string;
|
||||
neutralComments?: string;
|
||||
otherLines: string[];
|
||||
} {
|
||||
const comments = {
|
||||
correct: [] as string[],
|
||||
incorrect: [] as string[],
|
||||
neutral: [] as string[],
|
||||
};
|
||||
|
||||
const otherLines: string[] = [];
|
||||
|
||||
const feedbackIndicators = delimiters;
|
||||
|
||||
let currentFeedbackType: feedbackTypeOptions = "none";
|
||||
|
||||
for (const line of lines.map((l) => l)) {
|
||||
const lineFeedbackType: feedbackTypeOptions = line.startsWith(
|
||||
feedbackIndicators.correct
|
||||
)
|
||||
? "correct"
|
||||
: line.startsWith(feedbackIndicators.incorrect)
|
||||
? "incorrect"
|
||||
: line.startsWith(feedbackIndicators.neutral)
|
||||
? "neutral"
|
||||
: "none";
|
||||
|
||||
if (lineFeedbackType === "none" && currentFeedbackType !== "none") {
|
||||
const lineWithoutIndicator = line
|
||||
.replace(feedbackIndicators[currentFeedbackType], "")
|
||||
.trim();
|
||||
comments[currentFeedbackType].push(lineWithoutIndicator);
|
||||
} else if (lineFeedbackType !== "none") {
|
||||
const lineWithoutIndicator = line
|
||||
.replace(feedbackIndicators[lineFeedbackType], "")
|
||||
.trim();
|
||||
currentFeedbackType = lineFeedbackType;
|
||||
comments[lineFeedbackType].push(lineWithoutIndicator);
|
||||
} else {
|
||||
otherLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
const correctComments = comments.correct.filter((l) => l).join("\n");
|
||||
const incorrectComments = comments.incorrect.filter((l) => l).join("\n");
|
||||
const neutralComments = comments.neutral.filter((l) => l).join("\n");
|
||||
|
||||
return {
|
||||
correctComments: correctComments || undefined,
|
||||
incorrectComments: incorrectComments || undefined,
|
||||
neutralComments: neutralComments || undefined,
|
||||
otherLines,
|
||||
};
|
||||
},
|
||||
|
||||
formatFeedback(
|
||||
correctComments?: string,
|
||||
incorrectComments?: string,
|
||||
neutralComments?: string,
|
||||
delimiters: FeedbackDelimiters = defaultFeedbackDelimiters
|
||||
): string {
|
||||
let feedbackText = "";
|
||||
if (correctComments) {
|
||||
feedbackText += `${delimiters.correct} ${correctComments}\n`;
|
||||
}
|
||||
if (incorrectComments) {
|
||||
feedbackText += `${delimiters.incorrect} ${incorrectComments}\n`;
|
||||
}
|
||||
if (neutralComments) {
|
||||
feedbackText += `${delimiters.neutral} ${neutralComments}\n`;
|
||||
}
|
||||
// Ensure there's a blank line after feedback block so answers are separated
|
||||
if (feedbackText) feedbackText += "\n";
|
||||
return feedbackText;
|
||||
},
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
} from "@/features/local/utils/timeUtils";
|
||||
import { LocalQuiz } from "../localQuiz";
|
||||
import { quizQuestionMarkdownUtils } from "./quizQuestionMarkdownUtils";
|
||||
import { FeedbackDelimiters } from "./quizFeedbackMarkdownUtils";
|
||||
|
||||
const extractLabelValue = (input: string, label: string): string => {
|
||||
const pattern = new RegExp(`${label}: (.*?)\n`);
|
||||
@@ -104,7 +103,7 @@ const getQuizWithOnlySettings = (settings: string, name: string): LocalQuiz => {
|
||||
};
|
||||
|
||||
export const quizMarkdownUtils = {
|
||||
toMarkdown(quiz: LocalQuiz, delimiters?: FeedbackDelimiters): string {
|
||||
toMarkdown(quiz: LocalQuiz): string {
|
||||
if (!quiz) {
|
||||
throw Error(`quiz was undefined, cannot parse markdown`);
|
||||
}
|
||||
@@ -116,7 +115,7 @@ export const quizMarkdownUtils = {
|
||||
throw Error(`quiz ${quiz.name} is probably not a quiz`);
|
||||
}
|
||||
const questionMarkdownArray = quiz.questions.map((q) =>
|
||||
quizQuestionMarkdownUtils.toMarkdown(q, delimiters)
|
||||
quizQuestionMarkdownUtils.toMarkdown(q)
|
||||
);
|
||||
const questionDelimiter = "\n\n---\n\n";
|
||||
const questionMarkdown = questionMarkdownArray.join(questionDelimiter);
|
||||
@@ -134,11 +133,7 @@ Description: ${quiz.description}
|
||||
${questionMarkdown}`;
|
||||
},
|
||||
|
||||
parseMarkdown(
|
||||
input: string,
|
||||
name: string,
|
||||
delimiters?: FeedbackDelimiters
|
||||
): LocalQuiz {
|
||||
parseMarkdown(input: string, name: string): LocalQuiz {
|
||||
const splitInput = input.split("---\n");
|
||||
const settings = splitInput[0];
|
||||
const quizWithoutQuestions = getQuizWithOnlySettings(settings, name);
|
||||
@@ -146,7 +141,7 @@ ${questionMarkdown}`;
|
||||
const rawQuestions = splitInput.slice(1);
|
||||
const questions = rawQuestions
|
||||
.filter((str) => str.trim().length > 0)
|
||||
.map((q, i) => quizQuestionMarkdownUtils.parseMarkdown(q, i, delimiters));
|
||||
.map((q, i) => quizQuestionMarkdownUtils.parseMarkdown(q, i));
|
||||
|
||||
return {
|
||||
...quizWithoutQuestions,
|
||||
|
||||
@@ -1,49 +1,5 @@
|
||||
import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion";
|
||||
import { QuestionType } from "../localQuizQuestion";
|
||||
import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer";
|
||||
const _validFirstAnswerDelimiters = [
|
||||
"*a)",
|
||||
"a)",
|
||||
"*)",
|
||||
")",
|
||||
"[ ]",
|
||||
"[]",
|
||||
"[*]",
|
||||
"^",
|
||||
"=",
|
||||
];
|
||||
const _multipleChoicePrefix = ["a)", "*a)", "*)", ")"];
|
||||
const _multipleAnswerPrefix = ["[ ]", "[*]", "[]"];
|
||||
|
||||
const parseNumericalAnswer = (input: string): LocalQuizQuestionAnswer => {
|
||||
const trimmedInput = input.replace(/^=\s*/, "").trim();
|
||||
|
||||
// Check if it's a range answer: = [min, max]
|
||||
const minMaxPattern = /^\[([^,]+),\s*([^\]]+)\]$/;
|
||||
const rangeNumbericAnswerMatch = trimmedInput.match(minMaxPattern);
|
||||
|
||||
if (rangeNumbericAnswerMatch) {
|
||||
const minValue = parseFloat(rangeNumbericAnswerMatch[1].trim());
|
||||
const maxValue = parseFloat(rangeNumbericAnswerMatch[2].trim());
|
||||
const answer: LocalQuizQuestionAnswer = {
|
||||
correct: true,
|
||||
text: input.trim(),
|
||||
numericalAnswerType: "range_answer",
|
||||
numericAnswerRangeMin: minValue,
|
||||
numericAnswerRangeMax: maxValue,
|
||||
};
|
||||
return answer;
|
||||
}
|
||||
|
||||
// Otherwise, it's an exact answer
|
||||
const numericValue = parseFloat(trimmedInput);
|
||||
const answer: LocalQuizQuestionAnswer = {
|
||||
correct: true,
|
||||
text: input.trim(),
|
||||
numericalAnswerType: "exact_answer",
|
||||
numericAnswer: numericValue,
|
||||
};
|
||||
return answer;
|
||||
};
|
||||
|
||||
const parseMatchingAnswer = (input: string) => {
|
||||
const matchingPattern = /^\^?/;
|
||||
@@ -57,51 +13,14 @@ const parseMatchingAnswer = (input: string) => {
|
||||
return answer;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const quizQuestionAnswerMarkdownUtils = {
|
||||
parseMarkdown(
|
||||
input: string,
|
||||
questionType: QuestionType
|
||||
): LocalQuizQuestionAnswer {
|
||||
if (questionType === QuestionType.NUMERICAL) {
|
||||
return parseNumericalAnswer(input);
|
||||
}
|
||||
// getHtmlText(): string {
|
||||
// return MarkdownService.render(this.text);
|
||||
// }
|
||||
|
||||
parseMarkdown(input: string, questionType: string): LocalQuizQuestionAnswer {
|
||||
const isCorrect = input.startsWith("*") || input[1] === "*";
|
||||
|
||||
if (questionType === QuestionType.MATCHING) {
|
||||
return parseMatchingAnswer(input);
|
||||
}
|
||||
@@ -119,117 +38,4 @@ export const quizQuestionAnswerMarkdownUtils = {
|
||||
};
|
||||
return answer;
|
||||
},
|
||||
isAnswerLine: (trimmedLine: string): boolean => {
|
||||
return _validFirstAnswerDelimiters.some((prefix) =>
|
||||
trimmedLine.startsWith(prefix)
|
||||
);
|
||||
},
|
||||
getQuestionType: (
|
||||
linesWithoutPoints: string[],
|
||||
questionIndex: number // needed for debug logging
|
||||
): QuestionType => {
|
||||
const lastLine = linesWithoutPoints[linesWithoutPoints.length - 1]
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
if (linesWithoutPoints.length === 0) return QuestionType.NONE;
|
||||
if (lastLine === "essay") return QuestionType.ESSAY;
|
||||
if (lastLine === "short answer") return QuestionType.SHORT_ANSWER;
|
||||
if (lastLine === "short_answer") return QuestionType.SHORT_ANSWER;
|
||||
if (lastLine === "short_answer=")
|
||||
return QuestionType.SHORT_ANSWER_WITH_ANSWERS;
|
||||
if (lastLine.startsWith("=")) return QuestionType.NUMERICAL;
|
||||
|
||||
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;
|
||||
},
|
||||
getAnswers: (
|
||||
linesWithoutPoints: string[],
|
||||
questionIndex: number,
|
||||
questionType: QuestionType
|
||||
): { answers: LocalQuizQuestionAnswer[]; distractors: string[] } => {
|
||||
const typesWithAnswers: QuestionType[] = [
|
||||
QuestionType.MULTIPLE_CHOICE,
|
||||
QuestionType.MULTIPLE_ANSWERS,
|
||||
QuestionType.MATCHING,
|
||||
QuestionType.SHORT_ANSWER_WITH_ANSWERS,
|
||||
QuestionType.NUMERICAL,
|
||||
];
|
||||
if (!typesWithAnswers.includes(questionType)) {
|
||||
return { answers: [], distractors: [] };
|
||||
}
|
||||
|
||||
if (questionType == QuestionType.SHORT_ANSWER_WITH_ANSWERS)
|
||||
linesWithoutPoints = linesWithoutPoints.slice(
|
||||
0,
|
||||
linesWithoutPoints.length - 1
|
||||
);
|
||||
|
||||
const answerLines = getAnswerStringsWithMultilineSupport(
|
||||
linesWithoutPoints,
|
||||
questionIndex
|
||||
);
|
||||
|
||||
const allAnswers = answerLines.map((a) =>
|
||||
quizQuestionAnswerMarkdownUtils.parseMarkdown(a, questionType)
|
||||
);
|
||||
|
||||
// For matching questions, separate answers from distractors
|
||||
if (questionType === QuestionType.MATCHING) {
|
||||
const answers = allAnswers.filter((a) => a.text);
|
||||
const distractors = allAnswers
|
||||
.filter((a) => !a.text)
|
||||
.map((a) => a.matchedText ?? "");
|
||||
return { answers, distractors };
|
||||
}
|
||||
|
||||
return { answers: allAnswers, distractors: [] };
|
||||
},
|
||||
|
||||
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 if (question.questionType === "numerical") {
|
||||
if (answer.numericalAnswerType === "range_answer") {
|
||||
return `= [${answer.numericAnswerRangeMin}, ${answer.numericAnswerRangeMax}]`;
|
||||
}
|
||||
return `= ${answer.numericAnswer}`;
|
||||
} else {
|
||||
const questionLetter = String.fromCharCode(97 + index);
|
||||
const correctIndicator = answer.correct ? "*" : "";
|
||||
const questionTypeIndicator = `${correctIndicator}${questionLetter}) `;
|
||||
|
||||
return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,72 +1,151 @@
|
||||
import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion";
|
||||
import {
|
||||
quizFeedbackMarkdownUtils,
|
||||
FeedbackDelimiters,
|
||||
} from "./quizFeedbackMarkdownUtils";
|
||||
import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer";
|
||||
import { quizQuestionAnswerMarkdownUtils } from "./quizQuestionAnswerMarkdownUtils";
|
||||
|
||||
const splitLinesAndPoints = (input: string[]) => {
|
||||
const firstLineIsPoints = input[0].toLowerCase().includes("points: ");
|
||||
const _validFirstAnswerDelimiters = [
|
||||
"*a)",
|
||||
"a)",
|
||||
"*)",
|
||||
")",
|
||||
"[ ]",
|
||||
"[]",
|
||||
"[*]",
|
||||
"^",
|
||||
];
|
||||
const _multipleChoicePrefix = ["a)", "*a)", "*)", ")"];
|
||||
const _multipleAnswerPrefix = ["[ ]", "[*]", "[]"];
|
||||
|
||||
const textHasPointsLine =
|
||||
input.length > 0 &&
|
||||
input[0].includes(": ") &&
|
||||
input[0].split(": ").length > 1 &&
|
||||
!isNaN(parseFloat(input[0].split(": ")[1]));
|
||||
|
||||
const points =
|
||||
firstLineIsPoints && textHasPointsLine
|
||||
? parseFloat(input[0].split(": ")[1])
|
||||
: 1;
|
||||
|
||||
const linesWithoutPoints = firstLineIsPoints ? input.slice(1) : input;
|
||||
|
||||
return { points, lines: linesWithoutPoints };
|
||||
};
|
||||
|
||||
const getLinesBeforeAnswerLines = (lines: string[]): string[] => {
|
||||
const { linesWithoutAnswers } = lines.reduce(
|
||||
({ linesWithoutAnswers, taking }, currentLine) => {
|
||||
if (!taking)
|
||||
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
|
||||
|
||||
const lineIsAnswer =
|
||||
quizQuestionAnswerMarkdownUtils.isAnswerLine(currentLine);
|
||||
if (lineIsAnswer)
|
||||
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
|
||||
|
||||
return {
|
||||
linesWithoutAnswers: [...linesWithoutAnswers, currentLine],
|
||||
taking: true,
|
||||
};
|
||||
},
|
||||
{ linesWithoutAnswers: [] as string[], taking: true }
|
||||
);
|
||||
return linesWithoutAnswers;
|
||||
};
|
||||
|
||||
const removeQuestionTypeFromDescriptionLines = (
|
||||
linesWithoutAnswers: string[],
|
||||
questionType: QuestionType
|
||||
): string[] => {
|
||||
const questionTypesWithoutAnswers = ["essay", "short answer", "short_answer"];
|
||||
|
||||
const descriptionLines = questionTypesWithoutAnswers.includes(questionType)
|
||||
? linesWithoutAnswers.filter(
|
||||
(line) => !questionTypesWithoutAnswers.includes(line.toLowerCase())
|
||||
const getAnswerStringsWithMultilineSupport = (
|
||||
linesWithoutPoints: string[],
|
||||
questionIndex: number
|
||||
) => {
|
||||
const indexOfAnswerStart = linesWithoutPoints.findIndex((l) =>
|
||||
_validFirstAnswerDelimiters.some((prefix) =>
|
||||
l.trimStart().startsWith(prefix)
|
||||
)
|
||||
: linesWithoutAnswers;
|
||||
);
|
||||
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}`
|
||||
);
|
||||
}
|
||||
|
||||
return descriptionLines;
|
||||
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;
|
||||
if (
|
||||
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase().trim() ===
|
||||
"short_answer="
|
||||
)
|
||||
return QuestionType.SHORT_ANSWER_WITH_ANSWERS;
|
||||
|
||||
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[] => {
|
||||
if (questionType == QuestionType.SHORT_ANSWER_WITH_ANSWERS)
|
||||
linesWithoutPoints = linesWithoutPoints.slice(
|
||||
0,
|
||||
linesWithoutPoints.length - 1
|
||||
);
|
||||
const answerLines = getAnswerStringsWithMultilineSupport(
|
||||
linesWithoutPoints,
|
||||
questionIndex
|
||||
);
|
||||
|
||||
const answers = answerLines.map((a) =>
|
||||
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,
|
||||
delimiters?: FeedbackDelimiters
|
||||
): string {
|
||||
toMarkdown(question: LocalQuizQuestion): string {
|
||||
const answerArray = question.answers.map((a, i) =>
|
||||
quizQuestionAnswerMarkdownUtils.getAnswerMarkdown(question, a, i)
|
||||
getAnswerMarkdown(question, a, i)
|
||||
);
|
||||
|
||||
const distractorText =
|
||||
@@ -74,14 +153,6 @@ export const quizQuestionMarkdownUtils = {
|
||||
? question.matchDistractors?.map((d) => `\n^ - ${d}`).join("") ?? ""
|
||||
: "";
|
||||
|
||||
// Build feedback lines
|
||||
const feedbackText = quizFeedbackMarkdownUtils.formatFeedback(
|
||||
question.correctComments,
|
||||
question.incorrectComments,
|
||||
question.neutralComments,
|
||||
delimiters
|
||||
);
|
||||
|
||||
const answersText = answerArray.join("\n");
|
||||
const questionTypeIndicator =
|
||||
question.questionType === "essay" ||
|
||||
@@ -91,53 +162,91 @@ export const quizQuestionMarkdownUtils = {
|
||||
? `\n${QuestionType.SHORT_ANSWER_WITH_ANSWERS}`
|
||||
: "";
|
||||
|
||||
return `Points: ${question.points}\n${question.text}\n${feedbackText}${answersText}${distractorText}${questionTypeIndicator}`;
|
||||
return `Points: ${question.points}\n${question.text}\n${answersText}${distractorText}${questionTypeIndicator}`;
|
||||
},
|
||||
|
||||
parseMarkdown(
|
||||
input: string,
|
||||
questionIndex: number,
|
||||
delimiters?: FeedbackDelimiters
|
||||
): LocalQuizQuestion {
|
||||
const { points, lines } = splitLinesAndPoints(input.trim().split("\n"));
|
||||
parseMarkdown(input: string, questionIndex: number): LocalQuizQuestion {
|
||||
const lines = input.trim().split("\n");
|
||||
const firstLineIsPoints = lines[0].toLowerCase().includes("points: ");
|
||||
|
||||
const linesWithoutAnswers = getLinesBeforeAnswerLines(lines);
|
||||
const textHasPoints =
|
||||
lines.length > 0 &&
|
||||
lines[0].includes(": ") &&
|
||||
lines[0].split(": ").length > 1 &&
|
||||
!isNaN(parseFloat(lines[0].split(": ")[1]));
|
||||
|
||||
const questionType = quizQuestionAnswerMarkdownUtils.getQuestionType(
|
||||
lines,
|
||||
questionIndex
|
||||
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 };
|
||||
|
||||
const linesWithoutAnswersAndTypes = removeQuestionTypeFromDescriptionLines(
|
||||
linesWithoutAnswers,
|
||||
questionType
|
||||
return {
|
||||
linesWithoutAnswers: [...linesWithoutAnswers, currentLine],
|
||||
taking: true,
|
||||
};
|
||||
},
|
||||
{ linesWithoutAnswers: [] as string[], taking: true }
|
||||
);
|
||||
const questionType = getQuestionType(linesWithoutPoints, questionIndex);
|
||||
|
||||
const {
|
||||
correctComments,
|
||||
incorrectComments,
|
||||
neutralComments,
|
||||
otherLines: descriptionLines,
|
||||
} = quizFeedbackMarkdownUtils.extractFeedback(
|
||||
linesWithoutAnswersAndTypes,
|
||||
delimiters
|
||||
);
|
||||
const questionTypesWithoutAnswers = [
|
||||
"essay",
|
||||
"short answer",
|
||||
"short_answer",
|
||||
];
|
||||
|
||||
const { answers, distractors } = quizQuestionAnswerMarkdownUtils.getAnswers(
|
||||
lines,
|
||||
questionIndex,
|
||||
questionType
|
||||
);
|
||||
const descriptionLines = questionTypesWithoutAnswers.includes(
|
||||
questionType.toLowerCase()
|
||||
)
|
||||
? linesWithoutAnswers
|
||||
.slice(0, linesWithoutPoints.length)
|
||||
.filter(
|
||||
(line) =>
|
||||
!questionTypesWithoutAnswers.includes(line.toLowerCase())
|
||||
)
|
||||
: linesWithoutAnswers;
|
||||
|
||||
const description = descriptionLines.join("\n");
|
||||
|
||||
const typesWithAnswers = [
|
||||
"multiple_choice",
|
||||
"multiple_answers",
|
||||
"matching",
|
||||
"short_answer=",
|
||||
];
|
||||
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: descriptionLines.join("\n"),
|
||||
text: description,
|
||||
questionType,
|
||||
points,
|
||||
answers,
|
||||
answers: answersWithoutDistractors,
|
||||
matchDistractors: distractors,
|
||||
correctComments,
|
||||
incorrectComments,
|
||||
neutralComments,
|
||||
};
|
||||
return question;
|
||||
},
|
||||
|
||||
@@ -5,16 +5,11 @@ import {
|
||||
LocalQuiz,
|
||||
zodLocalQuiz,
|
||||
} from "@/features/local/quizzes/models/localQuiz";
|
||||
import {
|
||||
getCoursePathByName,
|
||||
getGlobalSettings,
|
||||
} from "../globalSettings/globalSettingsFileStorageService";
|
||||
import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { quizMarkdownUtils } from "./models/utils/quizMarkdownUtils";
|
||||
import { courseItemFileStorageService } from "../course/courseItemFileStorageService";
|
||||
import { getFeedbackDelimitersFromSettings } from "../globalSettings/globalSettingsUtils";
|
||||
import { assertValidFileName } from "@/services/fileNameValidation";
|
||||
|
||||
export const quizRouter = router({
|
||||
getQuiz: publicProcedure
|
||||
@@ -154,7 +149,6 @@ export async function updateQuizFile({
|
||||
quizName: string;
|
||||
quiz: LocalQuiz;
|
||||
}) {
|
||||
assertValidFileName(quizName);
|
||||
const courseDirectory = await getCoursePathByName(courseName);
|
||||
const folder = path.join(courseDirectory, moduleName, "quizzes");
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
@@ -165,9 +159,7 @@ export async function updateQuizFile({
|
||||
quizName + ".md"
|
||||
);
|
||||
|
||||
const globalSettings = await getGlobalSettings();
|
||||
const delimiters = getFeedbackDelimitersFromSettings(globalSettings);
|
||||
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz, delimiters);
|
||||
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||
console.log(`Saving quiz ${filePath}`);
|
||||
await fs.writeFile(filePath, quizMarkdown);
|
||||
}
|
||||
|
||||
@@ -25,13 +25,4 @@ export const directoriesRouter = router({
|
||||
.query(async ({ input: { folderPath } }) => {
|
||||
return await fileStorageService.settings.folderIsCourse(folderPath);
|
||||
}),
|
||||
directoryExists: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
relativePath: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { relativePath } }) => {
|
||||
return await fileStorageService.directoryExists(relativePath);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -72,15 +72,4 @@ export const fileStorageService = {
|
||||
}
|
||||
return { files, folders };
|
||||
},
|
||||
|
||||
async directoryExists(relativePath: string): Promise<boolean> {
|
||||
const fullPath = path.join(basePath, relativePath);
|
||||
// Security: ensure fullPath is inside basePath
|
||||
const resolvedBase = path.resolve(basePath);
|
||||
const resolvedFull = path.resolve(fullPath);
|
||||
if (!resolvedFull.startsWith(resolvedBase)) {
|
||||
return false;
|
||||
}
|
||||
return await directoryOrFileExists(fullPath);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,10 +23,3 @@ export const useDirectoryIsCourseQuery = (folderPath: string) => {
|
||||
trpc.directories.directoryIsCourse.queryOptions({ folderPath })
|
||||
);
|
||||
};
|
||||
|
||||
export const useDirectoryExistsQuery = (relativePath: string) => {
|
||||
const trpc = useTRPC();
|
||||
return useQuery(
|
||||
trpc.directories.directoryExists.queryOptions({ relativePath })
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
export function validateFileName(fileName: string): string {
|
||||
if (!fileName || fileName.trim() === "") {
|
||||
return "Name cannot be empty";
|
||||
}
|
||||
|
||||
const invalidChars = [":", "/", "\\", "*", "?", '"', "<", ">", "|"];
|
||||
|
||||
for (const char of fileName) {
|
||||
if (invalidChars.includes(char)) {
|
||||
return `Name contains invalid character: "${char}". Please avoid: ${invalidChars.join(
|
||||
" "
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (fileName !== fileName.trimEnd()) {
|
||||
return "Name cannot end with whitespace";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function assertValidFileName(fileName: string): void {
|
||||
const error = validateFileName(fileName);
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { markdownToHtmlNoImages } from './htmlMarkdownUtils';
|
||||
|
||||
describe('markdownToHtmlNoImages', () => {
|
||||
it('renders mermaid diagrams correctly using pako compression', () => {
|
||||
const markdown = `
|
||||
\`\`\`mermaid
|
||||
graph TD;
|
||||
A-->B;
|
||||
A-->C;
|
||||
B-->D;
|
||||
C-->D;
|
||||
\`\`\`
|
||||
`;
|
||||
const html = markdownToHtmlNoImages(markdown);
|
||||
|
||||
// The expected URL part for the graph above when compressed with pako
|
||||
const expectedUrlPart = "pako:eNqrVkrOT0lVslJKL0osyFAIcbGOyVMAAkddXTsnJLYzlO0EZMPUOIPZSjpKualFuYmZKUpW1UolGam5IONSUtMSS3NKlGprAQJ0Gx4";
|
||||
|
||||
expect(html).toContain(`https://mermaid.ink/img/${expectedUrlPart}`);
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { markdownToHtmlNoImages } from './htmlMarkdownUtils';
|
||||
|
||||
describe('markdownToHtmlNoImages', () => {
|
||||
it('moves caption into table when caption immediately precedes table', () => {
|
||||
const markdown = `<caption>My Table</caption>
|
||||
|
||||
| Header |
|
||||
| --- |
|
||||
| Cell |`;
|
||||
const html = markdownToHtmlNoImages(markdown);
|
||||
// We expect the caption to be inside the table
|
||||
expect(html).toMatch(/<table>\s*<caption>My Table<\/caption>/);
|
||||
});
|
||||
|
||||
it('renders table correctly without caption', () => {
|
||||
const markdown = `
|
||||
| Header |
|
||||
| --- |
|
||||
| Cell |`;
|
||||
const html = markdownToHtmlNoImages(markdown);
|
||||
expect(html).toMatch(/<table>/);
|
||||
expect(html).not.toMatch(/<caption>/);
|
||||
expect(html).toContain('Header');
|
||||
expect(html).toContain('Cell');
|
||||
});
|
||||
|
||||
it('moves caption with attributes into table', () => {
|
||||
const markdown = `<caption style="color:red">My Table</caption>
|
||||
|
||||
| Header |
|
||||
| --- |
|
||||
| Cell |`;
|
||||
const html = markdownToHtmlNoImages(markdown);
|
||||
expect(html).toMatch(/<table>\s*<caption style="color:red">My Table<\/caption>/);
|
||||
});
|
||||
|
||||
it('adds scope="col" to table headers', () => {
|
||||
const markdown = `
|
||||
| Header 1 | Header 2 |
|
||||
| --- | --- |
|
||||
| Cell 1 | Cell 2 |`;
|
||||
const html = markdownToHtmlNoImages(markdown);
|
||||
expect(html).toContain('<th scope="col">Header 1</th>');
|
||||
expect(html).toContain('<th scope="col">Header 2</th>');
|
||||
});
|
||||
|
||||
it('does not add an extra empty header row', () => {
|
||||
const markdown = `
|
||||
| Header |
|
||||
| --- |
|
||||
| Cell |`;
|
||||
const html = markdownToHtmlNoImages(markdown);
|
||||
expect(html).not.toContain('<th scope="col"></th>');
|
||||
const thCount = (html.match(/<th scope="col"/g) || []).length;
|
||||
expect(thCount).toBe(1);
|
||||
});
|
||||
|
||||
it('does not add scope="col" to raw HTML tables', () => {
|
||||
const markdown = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Raw Header</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Raw Cell</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
| MD Header |
|
||||
| --- |
|
||||
| MD Cell |`;
|
||||
const html = markdownToHtmlNoImages(markdown);
|
||||
|
||||
// Raw table should be untouched (or at least not have scope="col" added if it wasn't there)
|
||||
expect(html).toContain('<th>Raw Header</th>');
|
||||
expect(html).not.toContain('<th scope="col">Raw Header</th>');
|
||||
|
||||
// Markdown table should have scope="col"
|
||||
expect(html).toContain('<th scope="col">MD Header</th>');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import markedKatex from "marked-katex-extension";
|
||||
import pako from "pako";
|
||||
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
||||
|
||||
const mermaidExtension = {
|
||||
@@ -23,17 +22,9 @@ const mermaidExtension = {
|
||||
}
|
||||
},
|
||||
renderer(token: { text: string }) {
|
||||
const data = JSON.stringify({
|
||||
code: token.text,
|
||||
mermaid: { theme: "default" },
|
||||
});
|
||||
const compressed = pako.deflate(data, { level: 9 });
|
||||
const binaryString = Array.from(compressed, (byte) =>
|
||||
String.fromCharCode(byte)
|
||||
).join("");
|
||||
const base64 = btoa(binaryString).replace(/\+/g, "-").replace(/\//g, "_");
|
||||
const url = `https://mermaid.ink/img/pako:${base64}?type=svg`;
|
||||
// console.log(token.text, url);
|
||||
const base64 = btoa(token.text);
|
||||
const url = `https://mermaid.ink/img/${base64}?type=svg`;
|
||||
console.log(token.text, url);
|
||||
return `<img src="${url}" alt="Mermaid diagram" />`;
|
||||
},
|
||||
};
|
||||
@@ -47,23 +38,6 @@ marked.use(
|
||||
|
||||
marked.use({ extensions: [mermaidExtension] });
|
||||
|
||||
// We use a custom renderer instead of a regex replace because regex is too aggressive.
|
||||
// It would add scope="col" to raw HTML tables (which we want to leave alone).
|
||||
// The renderer only applies to markdown tables.
|
||||
marked.use({
|
||||
renderer: {
|
||||
tablecell(token) {
|
||||
const content = this.parser.parseInline(token.tokens);
|
||||
const { header, align } = token;
|
||||
const type = header ? "th" : "td";
|
||||
const alignAttr = align ? ` align="${align}"` : "";
|
||||
const scopeAttr = header ? ' scope="col"' : "";
|
||||
return `<${type}${scopeAttr}${alignAttr}>${content}</${type}>\n`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
export function extractImageSources(htmlString: string) {
|
||||
const srcUrls = [];
|
||||
const regex = /<img[^>]+src=["']?([^"'>]+)["']?/g;
|
||||
@@ -89,8 +63,6 @@ export function convertImagesToCanvasImages(
|
||||
}, {} as { [key: string]: string });
|
||||
|
||||
for (const imageSrc of imageSources) {
|
||||
if (imageSrc.startsWith("http://") || imageSrc.startsWith("https://"))
|
||||
continue;
|
||||
const destinationUrl = imageLookup[imageSrc];
|
||||
if (typeof destinationUrl === "undefined") {
|
||||
console.log(
|
||||
@@ -142,21 +114,8 @@ export function markdownToHTMLSafe({
|
||||
}
|
||||
|
||||
export function markdownToHtmlNoImages(markdownString: string) {
|
||||
const parsedHtml = marked.parse(markdownString, {
|
||||
async: false,
|
||||
pedantic: false,
|
||||
gfm: true,
|
||||
}) as string;
|
||||
|
||||
// Move caption inside table
|
||||
const htmlWithCaptionInTable = parsedHtml.replace(
|
||||
/(<caption[^>]*>[\s\S]*?<\/caption>)\s*(<table[^>]*>)/g,
|
||||
"$2$1"
|
||||
);
|
||||
|
||||
const clean = DOMPurify.sanitize(htmlWithCaptionInTable).replaceAll(
|
||||
/>[^<>]*<\/math>/g,
|
||||
"></math>"
|
||||
);
|
||||
const clean = DOMPurify.sanitize(
|
||||
marked.parse(markdownString, { async: false, pedantic: false, gfm: true })
|
||||
).replaceAll(/>[^<>]*<\/math>/g, "></math>");
|
||||
return clean;
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { markdownToHtmlNoImages } from './htmlMarkdownUtils';
|
||||
|
||||
describe('markdownToHtmlNoImages reference links', () => {
|
||||
it('renders reference links inside a table', () => {
|
||||
const markdown = `
|
||||
| Header |
|
||||
| --- |
|
||||
| [QuickStart][Fort1] |
|
||||
|
||||
[Fort1]: https://example.com/fort1
|
||||
`;
|
||||
const html = markdownToHtmlNoImages(markdown);
|
||||
expect(html).toContain('<a href="https://example.com/fort1">QuickStart</a>');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user