diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.svelte | 5 | ||||
| -rw-r--r-- | src/App.vue | 30 | ||||
| -rw-r--r-- | src/components/ActionBar.vue | 85 | ||||
| -rw-r--r-- | src/components/Editor.svelte | 20 | ||||
| -rw-r--r-- | src/components/EditorActions.svelte | 128 | ||||
| -rw-r--r-- | src/components/EditorLists.svelte | 160 | ||||
| -rw-r--r-- | src/components/EditorLists.vue | 152 | ||||
| -rw-r--r-- | src/components/FileUpload.svelte | 11 | ||||
| -rw-r--r-- | src/components/FileUpload.vue | 51 | ||||
| -rw-r--r-- | src/components/List.vue | 35 | ||||
| -rw-r--r-- | src/components/UIButton.vue | 32 | ||||
| -rw-r--r-- | src/features/parser.ts | 119 | ||||
| -rw-r--r-- | src/global.d.ts | 1 | ||||
| -rw-r--r-- | src/logic/EditorActions.ts | 33 | ||||
| -rw-r--r-- | src/main.ts | 11 | ||||
| -rw-r--r-- | src/models/EventInfo.ts | 88 | ||||
| -rw-r--r-- | src/models/Lab.ts | 59 | ||||
| -rw-r--r-- | src/models/PeerTeacher.ts | 85 | ||||
| -rw-r--r-- | src/router/index.ts | 27 | ||||
| -rw-r--r-- | src/shims-vue.d.ts | 6 | ||||
| -rw-r--r-- | src/store/index.ts | 36 | ||||
| -rw-r--r-- | src/stores.ts | 6 | ||||
| -rw-r--r-- | src/util/error.ts | 6 | ||||
| -rw-r--r-- | src/util/parser.ts | 155 | ||||
| -rw-r--r-- | src/views/Editor.vue | 44 | ||||
| -rw-r--r-- | src/views/Start.vue | 52 | ||||
| -rw-r--r-- | src/vuex-shim.d.ts | 17 |
27 files changed, 671 insertions, 783 deletions
diff --git a/src/App.svelte b/src/App.svelte new file mode 100644 index 0000000..4fbef54 --- /dev/null +++ b/src/App.svelte @@ -0,0 +1,5 @@ +<script lang="ts"> + import Editor from "./components/Editor.svelte"; +</script> + +<Editor /> diff --git a/src/App.vue b/src/App.vue deleted file mode 100644 index 5a1f608..0000000 --- a/src/App.vue +++ /dev/null @@ -1,30 +0,0 @@ -<template> - <router-view/> -</template> - -<style> -*, *:before, *:after { - box-sizing: inherit; - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; -} - -html { - box-sizing: border-box; -} - -html, body { - margin: 0; - padding: 0; -} - -#app { - height: 100vh; - max-height: 100vh; -} - -@media (prefers-color-scheme: dark) { - body { - background-color: #121212; - } -} -</style> diff --git a/src/components/ActionBar.vue b/src/components/ActionBar.vue deleted file mode 100644 index bfe3cdb..0000000 --- a/src/components/ActionBar.vue +++ /dev/null @@ -1,85 +0,0 @@ -<template> - <div id="action-bar"> - <FileUpload - :accept="'text/plain'" - :multiple="true" - @fileChanged="handlePtChange">Upload PT Schedule</FileUpload> - <FileUpload - :accept="'application/json'" - @fileChanged="handleLabChange">Import Lab Schedule</FileUpload> - <UIButton @click="save">Export</UIButton> - </div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import FileUpload from '@/components/FileUpload.vue'; -import { parseLabFile, parsePtSchedule } from '@/features/parser'; -import UIButton from '@/components/UIButton.vue'; -import PeerTeacher from '@/models/PeerTeacher'; - -export default defineComponent({ - name: 'ActionBar', - components: { - FileUpload, - UIButton, - }, - methods: { - async handleLabChange(files: File[]) { - const data = await parseLabFile(files[0]); - this.$store.commit('importLabs', data); - }, - async handlePtChange(files: File[]) { - const promises: Promise<PeerTeacher>[] = []; - - files.forEach((file) => { - promises.push(parsePtSchedule(file)); - }); - - const result = await Promise.all(promises); - this.$store.commit('addPeerTeachers', result); - }, - save() { - const database = { - labs: Object.fromEntries(this.$store.state.labs), - peerTeachers: Object.fromEntries(this.$store.state.peerTeachers), - }; - - const jsonObj = JSON.stringify(database, (_, value) => { - if (typeof value === 'object' && value instanceof Set) { - return [...value]; - } - return value; - }); - - const blob = new Blob([jsonObj], { type: 'text/json' }); - const anchor = document.createElement('a'); - const url = window.URL.createObjectURL(blob); - anchor.href = url; - anchor.download = 'pt-db.json'; - anchor.style.display = 'none'; - - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - window.URL.revokeObjectURL(url); - }, - }, -}); -</script> - -<style> -#action-bar { - max-width: 100vw; - overflow-x: auto; - white-space: nowrap; -} - -#action-bar > * { - margin-left: 0.5rem; -} - -#action-bar > *:first-child { - margin-left: 0; -} -</style> diff --git a/src/components/Editor.svelte b/src/components/Editor.svelte new file mode 100644 index 0000000..c8a30c6 --- /dev/null +++ b/src/components/Editor.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import EditorActions from "./EditorActions.svelte"; + import EditorLists from "./EditorLists.svelte"; +</script> + +<div id="editor"> + <EditorActions /> + <EditorLists /> +</div> + +<style> + #editor { + display: flex; + flex-direction: column; + } + + :global(#action-bar) { + flex-shrink: 0; + } +</style> diff --git a/src/components/EditorActions.svelte b/src/components/EditorActions.svelte new file mode 100644 index 0000000..fa2a041 --- /dev/null +++ b/src/components/EditorActions.svelte @@ -0,0 +1,128 @@ +<script lang="ts"> + import Button, { Label } from "@smui/button"; + import IconButton from "@smui/icon-button"; + import Snackbar, { Actions } from "@smui/snackbar"; + import FileUpload from "./FileUpload.svelte"; + import { + parseDatabaseFile, + parseLabScheduleFile, + parsePTFile, + } from "../logic/EditorActions"; + import { labStore, ptStore } from "../stores"; + + let ptSchedules: FileList | null; + let labSchedule: FileList | null; + let dbFile: FileList | null; + let snackbar; + let snackbarText; + + $: { + if (ptSchedules?.length) { + const promises = [...ptSchedules].map((file) => parsePTFile(file)); + Promise.allSettled(promises) + .then((results) => + results.flatMap((result) => { + if (result.status === "fulfilled") { + ptStore.update((val) => val.set(result.value.id, result.value)); + return []; + } else { + return [result]; + } + }) + ) + .then((failed) => { + if (failed.length) { + snackbarText = `Failed to add ${failed.length} PTs. See console for details.`; + snackbar.open(); + } + }); + } + } + + $: { + if (labSchedule?.length) { + parseLabScheduleFile(labSchedule[0]) + .then((labs) => { + labStore.update(() => new Map(labs.map((lab) => [lab.id, lab]))); + }) + .catch(() => { + snackbarText = + "Failed to import lab schedule. See console for details."; + snackbar.open(); + }); + } + } + + $: { + if (dbFile?.length) { + parseDatabaseFile(dbFile[0]) + .then((database) => { + labStore.set(database.labs); + ptStore.set(database.peerTeachers); + }) + .catch(() => { + snackbarText = "Failed to import database. See console for details."; + snackbar.open(); + }); + } + } + + function exportDB() { + const peerTeachers = [...$ptStore.values()]; + const labs = [...$labStore.values()]; + const database = { + labs: labs, + peerTeachers: peerTeachers, + }; + + const dbObj = JSON.stringify(database, (_, value) => { + // Need to manually convert the PeerTeacher objects' + // `labs` set to an array because `JSON.stringify` doesn't + // support "stringing" it out of the box + if (typeof value === "object" && value instanceof Set) { + return [...value]; + } + return value; + }); + + const blob = new Blob([dbObj], { type: "text/json" }); + const anchor = document.createElement("a"); + const url = window.URL.createObjectURL(blob); + anchor.href = url; + anchor.download = "pt-db.json"; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + window.URL.revokeObjectURL(url); + } +</script> + +<div id="action-bar"> + <FileUpload accept="text/plain" multiple={true} bind:files={ptSchedules}> + <Label>Add PT</Label> + </FileUpload> + <FileUpload accept="application/json" bind:files={labSchedule}> + <Label>Import Labs</Label> + </FileUpload> + <FileUpload accept="application/json" bind:files={dbFile}> + <Label>Import DB</Label> + </FileUpload> + <Button variant="raised" ripple={false} on:click={exportDB}> + <Label>Export DB</Label> + </Button> +</div> +<Snackbar bind:this={snackbar} labelText={snackbarText}> + <Label /> + <Actions> + <IconButton class="material-icons" title="Dismiss">close</IconButton> + </Actions> +</Snackbar> + +<style> + #action-bar { + max-width: 100vw; + overflow-x: auto; + white-space: nowrap; + } +</style> diff --git a/src/components/EditorLists.svelte b/src/components/EditorLists.svelte new file mode 100644 index 0000000..1b2084a --- /dev/null +++ b/src/components/EditorLists.svelte @@ -0,0 +1,160 @@ +<script lang="ts"> + import IconButton from "@smui/icon-button"; + import List, { + Item, + Meta, + PrimaryText, + SecondaryText, + Text, + } from "@smui/list"; + import type PeerTeacher from "../models/PeerTeacher"; + import { labStore, ptStore } from "../stores"; + + let selectedPeerTeacher: PeerTeacher | undefined; + + $: peerTeachers = [...$ptStore.values()].sort((a, b) => + a.lastname.toUpperCase() === b.lastname.toUpperCase() + ? a.firstname.toUpperCase().localeCompare(b.firstname.toUpperCase()) + : a.lastname.toUpperCase().localeCompare(b.lastname.toUpperCase()) + ); + + $: labs = [...$labStore.values()].sort((a, b) => a.id - b.id); + + $: assignedLabs = [...(selectedPeerTeacher?.labs.values() ?? [])] + .flatMap((labId) => { + const lab = $labStore.get(labId); + return lab === undefined ? [] : [lab]; + }) + .sort((a, b) => a.id - b.id); + + $: compatibleLabs = labs.filter( + (lab) => + !selectedPeerTeacher?.labs.has(lab.id) && + !selectedPeerTeacher?.conflictsWith(lab.event) && + !assignedLabs.some((assignment) => + assignment.event.conflictsWith(lab.event) + ) + ); + + function deletePT(id: number) { + if (selectedPeerTeacher?.id === id) { + selectedPeerTeacher = undefined; + } + + ptStore.update((val) => { + val.delete(id); + return val; + }); + } + + function assignLab(id: number) { + selectedPeerTeacher?.labs.add(id); + // Self assignemnt to update `assignedLabs` and `compatibleLabs` + selectedPeerTeacher = selectedPeerTeacher; + } + + function unassignLab(id: number) { + selectedPeerTeacher?.labs.delete(id); + // Self assignemnt to update `assignedLabs` and `compatibleLabs` + selectedPeerTeacher = selectedPeerTeacher; + } +</script> + +<div id="editor-lists"> + <div class="column"> + <h3 class="col-header">Peer Teachers</h3> + <List twoLine singleSelection class="editor-list"> + {#each peerTeachers as pt} + <Item on:SMUI:action={() => (selectedPeerTeacher = pt)}> + <Text> + <PrimaryText>{pt.name}</PrimaryText> + <SecondaryText>{pt.id}</SecondaryText> + </Text> + <Meta> + <IconButton + class="material-icons" + on:click$stopPropagation={() => { + deletePT(pt.id); + }} + > + remove_circle + </IconButton> + </Meta> + </Item> + {/each} + </List> + </div> + <div class="column"> + <h3 class="col-header">Labs</h3> + <List threeLine class="editor-list"> + {#each compatibleLabs as lab} + <Item> + <Text> + <PrimaryText>{lab.course}-{lab.section}</PrimaryText> + <SecondaryText>{lab.time}</SecondaryText> + <SecondaryText>{lab.location}</SecondaryText> + </Text> + <Meta class="material-icons"> + <IconButton + class="material-icons" + on:click$stopPropagation={() => { + assignLab(lab.id); + }} + > + add_circle + </IconButton> + </Meta> + </Item> + {/each} + </List> + </div> + <div class="column"> + <h3 class="col-header"> + {selectedPeerTeacher?.name ?? "PT"} - Assigned Labs + </h3> + <List threeLine class="editor-list"> + {#each assignedLabs as lab} + <Item> + <Text> + <PrimaryText>{lab.course}-{lab.section}</PrimaryText> + <SecondaryText>{lab.time}</SecondaryText> + <SecondaryText>{lab.location}</SecondaryText> + </Text> + <Meta class="material-icons"> + <IconButton + class="material-icons" + on:click$stopPropagation={() => { + unassignLab(lab.id); + }} + > + remove_circle + </IconButton> + </Meta> + </Item> + {/each} + </List> + </div> +</div> + +<style> + #editor-lists { + display: flex; + max-height: inherit; + min-height: 0; + overflow-x: auto; + } + + .col-header { + font-family: Roboto, sans-serif; + } + + .column { + flex: 1; + min-width: 15em; + overflow: auto; + } + + * :global(.editor-list) { + border: 1px solid black; + } +</style> diff --git a/src/components/EditorLists.vue b/src/components/EditorLists.vue deleted file mode 100644 index dff34f4..0000000 --- a/src/components/EditorLists.vue +++ /dev/null @@ -1,152 +0,0 @@ -<template> - <div id="editor-lists"> - <div class="column"> - <h3 class="column-header">Peer Teachers</h3> - <List - :items="peerTeachers" - @selection-changed="handlePtClick" - #default="{ item: { id, name } }"> - <span class="list-item"> - {{ name }} <button @click.stop="deletePeerTeacher(id)">x</button> - </span> - </List> - </div> - <div class="column"> - <h3 class="column-header">Labs</h3> - <List :items="compatibleLabs" #default="{ item: { fullInfo, id } }"> - <span class="list-item"> - {{ fullInfo }} <button @click.stop="assignLab(id)">+</button> - </span> - </List> - </div> - <div class="column"> - <h3 class="column-header">{{ this.selectedPeerTeacher.name }}</h3> - <List :items="selectedPeerTeacherAssignments" #default="{ item: { fullInfo, id } }"> - <span class="list-item"> - {{ fullInfo }} <button @click.stop="unassignLab(id)">x</button> - </span> - </List> - </div> - </div> -</template> - -<script lang="ts"> -import { defineComponent, PropType } from 'vue'; -import List from '@/components/List.vue'; -import PeerTeacher from '@/models/PeerTeacher'; -import Lab from '@/models/Lab'; - -export default defineComponent({ - name: 'EditorLists', - components: { - List, - }, - props: { - peerTeachers: { - type: Array as PropType<PeerTeacher[]>, - default: () => [], - }, - labs: { - type: Array as PropType<Lab[]>, - default: () => [], - }, - }, - computed: { - compatibleLabs(): Lab[] { - const temp = this.labs.filter((lab) => (!this.selectedPeerTeacher.assignedLabs.has(lab.id) - && !this.selectedPeerTeacher.conflictsWith(lab.event))); - - const currentAssignments = this.selectedPeerTeacherAssignments; - return temp.filter((lab) => !currentAssignments - .some((assignedLab) => lab.event.conflictsWith(assignedLab.event))); - }, - selectedPeerTeacherAssignments(): Lab[] { - return Array.from(this.selectedPeerTeacher.assignedLabs.values()) - .flatMap((id) => { - const lab = this.$store.state.labs.get(id); - return (lab === undefined) ? [] : [lab]; - }).sort((a, b) => a.course - b.course || a.section - b.section); - }, - }, - data() { - return { - selectedPeerTeacher: new PeerTeacher(), - }; - }, - methods: { - handlePtClick(peerTeacher: PeerTeacher) { - this.selectedPeerTeacher = peerTeacher; - }, - deletePeerTeacher(id: number) { - if (this.selectedPeerTeacher.id === id) { - this.selectedPeerTeacher = new PeerTeacher(); - } - this.$store.commit('deletePeerTeacher', id); - }, - assignLab(id: string) { - if (this.selectedPeerTeacher.id !== 0) { - this.selectedPeerTeacher.assignedLabs.add(id); - } - }, - unassignLab(id: string) { - this.selectedPeerTeacher.assignedLabs.delete(id); - }, - }, -}); -</script> - -<style> -#editor-lists { - display: flex; - max-height: inherit; - min-height: 0; -} - -.column-header { - margin: 0; -} - -.column { - flex: 1; - overflow: auto; -} - -.list-item { - align-items: center; - background-color: #f0f0f0; - display: flex; - justify-content: space-between; - margin: 0.5em; - padding: 0.5em; -} - -.list-item:hover { - background-color: #c0c0c0; -} - -.list-item > button { - background-color: #500000; - border: none; - color: white; - font-size: 1em; -} - -@media (prefers-color-scheme: dark) { - .column-header { - color: white; - } - - .list-item { - background-color: #303030; - color: white; - } - - .list-item:hover { - background-color: #707070; - } - - .list-item > button { - background-color: #81302b; - } -} -</style> diff --git a/src/components/FileUpload.svelte b/src/components/FileUpload.svelte new file mode 100644 index 0000000..d325e32 --- /dev/null +++ b/src/components/FileUpload.svelte @@ -0,0 +1,11 @@ +<script lang="ts"> + export let accept = ""; + export let multiple = false; + export let files: FileList | null = null; +</script> + +<label class="mdc-button mdc-button--raised mdc-ripple-upgraded"> + <div class="mdc-button__ripple" /> + <slot>Upload</slot> + <input type="file" {accept} {multiple} bind:files hidden /> +</label> diff --git a/src/components/FileUpload.vue b/src/components/FileUpload.vue deleted file mode 100644 index 082fc9d..0000000 --- a/src/components/FileUpload.vue +++ /dev/null @@ -1,51 +0,0 @@ -<template> - <label class="file-upload-lbl"> - <input - type="file" - :accept="accept" - @change="$emit('fileChanged', $event.target.files)" - :multiple="multiple" hidden /> - <slot>Upload</slot> - </label> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - name: 'FileUpload', - props: { - accept: { - type: String, - required: false, - }, - multiple: { - type: Boolean, - default: false, - }, - }, - emits: { - fileChanged: null, - }, -}); -</script> - -<style scoped> -.file-upload-lbl { - background-color: #500000; - color: white; - padding: 0.5em; - text-align: center; -} - -.file-upload-lbl:hover { - color: grey; - cursor: pointer; -} - -@media (prefers-color-scheme: dark) { - .file-upload-lbl { - background-color: #81302b; - } -} -</style> diff --git a/src/components/List.vue b/src/components/List.vue deleted file mode 100644 index 4732a9e..0000000 --- a/src/components/List.vue +++ /dev/null @@ -1,35 +0,0 @@ -<template> - <ul class="list"> - <li - v-for="item in items" - :key="item.id" - @click="$emit('selectionChanged', item)"> - <slot :item="item">{{ item }}</slot> - </li> - </ul> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - name: 'List', - props: { - items: { - type: Array, - default: [] as any[], - }, - }, - emits: { - selectionChanged: null, - }, -}); -</script> - -<style scoped> -.list { - list-style-type: none; - margin: 0; - padding: 0; -} -</style> diff --git a/src/components/UIButton.vue b/src/components/UIButton.vue deleted file mode 100644 index 57b155b..0000000 --- a/src/components/UIButton.vue +++ /dev/null @@ -1,32 +0,0 @@ -<template> - <button class="ui-button"><slot></slot></button> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - name: 'UIButton', -}); -</script> - -<style> -.ui-button { - background-color: #500000; - border: none; - color: white; - font-size: 1em; - padding: 0.5em; -} - -.ui-button:hover { - color: grey; - cursor: pointer; -} - -@media (prefers-color-scheme: dark) { - .ui-button { - background-color: #81302b; - } -} -</style> diff --git a/src/features/parser.ts b/src/features/parser.ts deleted file mode 100644 index 9d61946..0000000 --- a/src/features/parser.ts +++ /dev/null @@ -1,119 +0,0 @@ -import Lab from '@/models/Lab'; -import PeerTeacher from '@/models/PeerTeacher'; -import EventInfo from '../models/EventInfo'; - -export async function parseLabFile(file: File): Promise<Lab[]> { - const validCourses = [ - '110', - '111', - '121', - '206', - '221', - // '222', - '312', - '313', - // '314', - '315', - ]; - const result: Lab[] = []; - - const text = await file.text(); - let jsonData; - try { - jsonData = JSON.parse(text); - } catch (e) { - throw new Error(e); - } - const labs = jsonData.data; - - labs.forEach((lab: any) => { - if (validCourses.includes(lab.courseNumber)) { - const newLab = new Lab(lab.courseNumber, lab.sequenceNumber); - - lab.meetingsFaculty.every((meeting: any) => { - const { meetingTime } = meeting; - - if (meetingTime.meetingType === 'LAB') { - let days = ''; - days += meetingTime.monday ? 'M' : ''; - days += meetingTime.tuesday ? 'T' : ''; - days += meetingTime.wednesday ? 'W' : ''; - days += meetingTime.thursday ? 'R' : ''; - days += meetingTime.friday ? 'F' : ''; - - newLab.event.days = days; - newLab.event.start = parseInt(meetingTime.beginTime, 10); - newLab.event.end = parseInt(meetingTime.endTime, 10); - - return false; - } - - return true; - }); - - result.push(newLab); - } - }); - - return result; -} - -export async function parsePtSchedule(file: File): Promise<PeerTeacher> { - const text = await file.text(); - const peerTeacher = new PeerTeacher(); - // namePatter: <firstname> <lastname> <uin> - const namePattern = /^(.*)\s(.*)\s(\d{9})/; - // eventPattern (24hr time): MTWRF hh:mm - hh:mm - const eventPattern = /^(M?T?W?R?F?)\s(\d{1,2}:\d{2})\s?-\s?(\d{1,2}:\d{2})/; - const lines = text.split('\n').filter((line) => line.trim()); - - lines.forEach((line) => { - const ptName = line.match(namePattern); - if (ptName) { - [, peerTeacher.firstname, peerTeacher.lastname] = ptName; - peerTeacher.uin = parseInt(ptName[3], 10); - } - - const eventMatch = line.match(eventPattern); - if (eventMatch) { - const newEvent = new EventInfo(eventMatch[1]); - newEvent.start = parseInt(eventMatch[2].replace(':', ''), 10); - newEvent.end = parseInt(eventMatch[3].replace(':', ''), 10); - peerTeacher.events.push(newEvent); - } - }); - - return peerTeacher; -} - -export async function parsePtDatabase(file: File): Promise<{ - labs: Map<string, Lab>, - peerTeachers: Map<number, PeerTeacher> -}> { - const text = await file.text(); - const jsonObj = JSON.parse(text); - const result = { - labs: new Map(), - peerTeachers: new Map(), - }; - - Object.keys(jsonObj.labs).forEach((key) => { - const { course, section, event: { days, start, end } } = jsonObj.labs[key]; - result.labs.set(key, new Lab(course, - section, new EventInfo(days, start, end))); - }); - - Object.keys(jsonObj.peerTeachers).forEach((key) => { - const pt = jsonObj.peerTeachers[key]; - const ptObj = new PeerTeacher(pt.firstname, pt.lastname, pt.uin); - ptObj.events = pt.events.map((eventObj: any) => new EventInfo(eventObj.days, - eventObj.start, eventObj.end)); - ptObj.assignedLabs = new Set(); - pt.assignedLabs.forEach((labId: string) => { - ptObj.assignedLabs.add(labId); - }); - result.peerTeachers.set(key, ptObj); - }); - - return result; -} diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..0e72969 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1 @@ +/// <reference types="svelte" />
\ No newline at end of file diff --git a/src/logic/EditorActions.ts b/src/logic/EditorActions.ts new file mode 100644 index 0000000..4a83fbc --- /dev/null +++ b/src/logic/EditorActions.ts @@ -0,0 +1,33 @@ +import { parseDatabase, parseLabSchedule, parsePTSchedule } from "../util/parser"; + +export async function parsePTFile(file: File) { + try { + const text = await file.text(); + return parsePTSchedule(text); + } catch (error) { + console.error(file.name, error); + throw error; + } +} + +export async function parseLabScheduleFile(file: File) { + const text = await file.text(); + try { + const labSchedule = JSON.parse(text); + return parseLabSchedule(labSchedule); + } catch (error) { + console.error(file.name, error); + throw error; + } +} + +export async function parseDatabaseFile(file: File) { + const text = await file.text(); + try { + const database = JSON.parse(text); + return parseDatabase(database); + } catch (error) { + console.error(file.name, error); + throw error; + } +} diff --git a/src/main.ts b/src/main.ts index c673f53..4c473fa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ -import { createApp } from 'vue'; -import App from './App.vue'; -import router from './router'; -import store from './store'; +import App from './App.svelte'; -createApp(App).use(store).use(router).mount('#app'); +const app = new App({ + target: document.body +}); + +export default app;
\ No newline at end of file diff --git a/src/models/EventInfo.ts b/src/models/EventInfo.ts index 962ec70..b0ac25b 100644 --- a/src/models/EventInfo.ts +++ b/src/models/EventInfo.ts @@ -1,46 +1,60 @@ -export default class EventInfo { - days: string; - - start: number; - - end: number; - - constructor(days: string = '', start = 0, end = 0) { - this.days = days; - this.start = start; - this.end = end; - } - - static timeToStr(time: number) { - let hour = Math.floor(time / 100); - const minute = time % 100; - const meridiem = (hour < 12) ? 'AM' : 'PM'; +interface EventInfoSerializeInfo { + days: string, + start: number, + end: number +} - if (hour === 0) { - hour = 12; - } else if (hour > 12) { - hour -= 12; +export default class EventInfo { + days: string; + start: number; + end: number; + + constructor(days: string, start: number | string, end: number | string) { + if (typeof start === "string") { + start = parseInt(start, 10); + } + if (typeof end === "string") { + end = parseInt(end, 10); + } + + this.days = days; + this.start = start; + this.end = end; } - if (minute < 10) { - return `${hour}:0${minute} ${meridiem}`; + static fromJSON({days, start, end}: EventInfoSerializeInfo) { + return new EventInfo(days, start, end); } - return `${hour}:${minute} ${meridiem}`; - } - conflictsWith(event: EventInfo) { - const daysConflict = event.days.match(new RegExp(`[${this.days}]`)); + static timeToStr(time: number) { + let hour = Math.floor(time / 100); + const minute = time % 100; + const meridiem = (hour < 12) ? 'AM' : 'PM'; + + if (hour === 0) { + hour = 12; + } else if (hour > 12) { + hour -= 12; + } + + if (minute < 10) { + return `${hour}:0${minute} ${meridiem}`; + } + return `${hour}:${minute} ${meridiem}`; + } - if (daysConflict) { - return (this.start <= event.end) && (event.start <= this.end); + conflictsWith(event: EventInfo) { + const daysConflict = event.days.match(new RegExp(`[${this.days}]`)); + return daysConflict && this.start <= event.end && event.start <= this.end; } - return false; - } - get info() { - if (this.days === '') { - return 'WEB'; + get info() { + if(this.days === "") { + return `WEB`; + }else if(this.start === -1 || this.end === -1) { + return `${this.days}`; + } else { + return `${this.days} ${EventInfo.timeToStr(this.start)}-${EventInfo.timeToStr(this.end)}`; + } } - return `${this.days} ${EventInfo.timeToStr(this.start)}-${EventInfo.timeToStr(this.end)}`; - } -} +}
\ No newline at end of file diff --git a/src/models/Lab.ts b/src/models/Lab.ts index a6972ef..d23cffb 100644 --- a/src/models/Lab.ts +++ b/src/models/Lab.ts @@ -1,23 +1,50 @@ -import EventInfo from '@/models/EventInfo'; +import EventInfo from "./EventInfo"; + +interface LabSerializeInfo { + course: number, + section: number, + event: { + days: string, + start: number, + end: number + }, + building: string, + room: string +} export default class Lab { - course: number; + id: number; + course: number; + section: number; + event: EventInfo; + building: string; + room: string; - section: number; + constructor(course: number | string, section: number | string, event: EventInfo, building = "", room = "") { + if(typeof course === "string") { + course = parseInt(course, 10); + } + if(typeof section === "string") { + section = parseInt(section, 10); + } - event: EventInfo; + this.id = parseInt(`${course}${section}`, 10); + this.course = course; + this.section = section; + this.event = event; + this.building = building; + this.room = room; + } - constructor(course = 0, section = 0, event = new EventInfo()) { - this.course = course; - this.section = section; - this.event = event; - } + static fromJSON({course, section, event, building, room}: LabSerializeInfo) { + return new Lab(course, section, EventInfo.fromJSON(event), building, room); + } - get id() { - return `${this.course}-${this.section}`; - } + get time() { + return this.event.info; + } - get fullInfo() { - return `${this.id} ${this.event.info}`; - } -} + get location() { + return `${this.building}-${this.room}`; + } +}
\ No newline at end of file diff --git a/src/models/PeerTeacher.ts b/src/models/PeerTeacher.ts index f387431..5d955fd 100644 --- a/src/models/PeerTeacher.ts +++ b/src/models/PeerTeacher.ts @@ -1,41 +1,48 @@ -import EventInfo from './EventInfo'; +import EventInfo from "./EventInfo"; + +interface PeerTeacherSerializeInfo { + id: number, + firstname: string, + lastname: string, + events: { + days: string, + start: number, + end: number + }[], + labs: number[] +} export default class PeerTeacher { - firstname: string; - - lastname: string; - - uin: number; - - events: EventInfo[]; - - assignedLabs: Set<string>; - - constructor(firstname = '', lastname = '', uin = 0) { - this.firstname = firstname; - this.lastname = lastname; - this.uin = uin; - this.events = []; - this.assignedLabs = new Set(); - } - - conflictsWith(event: EventInfo) { - let conflicts = false; - this.events.every((item) => { - if (item.conflictsWith(event)) { - conflicts = true; - return false; - } - return true; - }); - return conflicts; - } - - get name() { - return `${this.firstname} ${this.lastname}`; - } - - get id() { - return this.uin; - } -} + id: number; + firstname: string; + lastname: string; + events: EventInfo[]; + labs: Set<number>; + + constructor(id: number | string, firstname: string, lastname: string) { + if(typeof id === "string") { + id = parseInt(id, 10); + } + + this.id = id; + this.firstname = firstname; + this.lastname = lastname; + this.events = []; + this.labs = new Set(); + } + + static fromJSON({id, firstname, lastname, events, labs}: PeerTeacherSerializeInfo) { + const pt = new PeerTeacher(id, firstname, lastname); + pt.events = events.map(e => EventInfo.fromJSON(e)); + pt.labs = new Set(labs); + return pt; + } + + conflictsWith(event: EventInfo) { + return this.events.some(item => item.conflictsWith(event)); + } + + get name() { + return `${this.firstname} ${this.lastname}`; + } +}
\ No newline at end of file diff --git a/src/router/index.ts b/src/router/index.ts deleted file mode 100644 index a36cb7e..0000000 --- a/src/router/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createRouter, createWebHashHistory } from 'vue-router'; -import Editor from '../views/Editor.vue'; -import Start from '../views/Start.vue'; - -const routes = [ - { - path: '/', - name: 'Start', - component: Start, - }, - { - path: '/editor', - name: 'Editor', - // route level code-splitting - // this generates a separate chunk (about.[hash].js) for this route - // which is lazy-loaded when the route is visited. - // component: () => import(/* webpackChunkName: "about" */ '../views/Editor.vue'), - component: Editor, - }, -]; - -const router = createRouter({ - history: createWebHashHistory(), - routes, -}); - -export default router; diff --git a/src/shims-vue.d.ts b/src/shims-vue.d.ts deleted file mode 100644 index 3804a43..0000000 --- a/src/shims-vue.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* eslint-disable */ -declare module '*.vue' { - import type { DefineComponent } from 'vue' - const component: DefineComponent<{}, {}, any> - export default component -} diff --git a/src/store/index.ts b/src/store/index.ts deleted file mode 100644 index e079259..0000000 --- a/src/store/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createStore } from 'vuex'; -import Lab from '@/models/Lab'; -import PeerTeacher from '@/models/PeerTeacher'; - -export default createStore({ - state: { - labs: new Map(), - peerTeachers: new Map(), - }, - mutations: { - setLabs(state, labs: Map<string, Lab>) { - state.labs = labs; - }, - setPeerTeachers(state, peerTeachers: Map<number, PeerTeacher>) { - state.peerTeachers = peerTeachers; - }, - importLabs(state, labs: Lab[]) { - state.labs.clear(); - labs.forEach((lab) => { - state.labs.set(lab.id, lab); - }); - }, - addPeerTeachers(state, peerTeachers: PeerTeacher[]) { - peerTeachers.forEach((pt) => { - state.peerTeachers.set(pt.id, pt); - }); - }, - deletePeerTeacher(state, id: number) { - state.peerTeachers.delete(id); - }, - }, - actions: { - }, - modules: { - }, -}); diff --git a/src/stores.ts b/src/stores.ts new file mode 100644 index 0000000..f6c3a9b --- /dev/null +++ b/src/stores.ts @@ -0,0 +1,6 @@ +import { writable } from "svelte/store"; +import type Lab from "./models/Lab"; +import type PeerTeacher from "./models/PeerTeacher"; + +export const ptStore = writable(new Map<number, PeerTeacher>()); +export const labStore = writable(new Map<number, Lab>());
\ No newline at end of file diff --git a/src/util/error.ts b/src/util/error.ts new file mode 100644 index 0000000..5380849 --- /dev/null +++ b/src/util/error.ts @@ -0,0 +1,6 @@ +export class PeerTeacherImportError extends Error { + constructor(message) { + super(message); + this.name = "PeerTeacherImportError"; + } +}
\ No newline at end of file diff --git a/src/util/parser.ts b/src/util/parser.ts new file mode 100644 index 0000000..2f1bdd4 --- /dev/null +++ b/src/util/parser.ts @@ -0,0 +1,155 @@ +import EventInfo from "../models/EventInfo"; +import Lab from "../models/Lab"; +import PeerTeacher from "../models/PeerTeacher"; +import { PeerTeacherImportError } from "./error"; + +interface LabSchedule { + data: { + courseNumber: string, + sequenceNumber: string, + meetingsFaculty: { + meetingTime: { + beginTime: string | null, + building: string, + endTime: string | null, + friday: boolean, + meetingType: string, + monday: boolean, + room: string, + thursday: boolean, + tuesday: boolean, + wednesday: boolean + } + }[] + }[] +}; + +interface DatabaseFile { + labs: { + id: number, + course: number, + section: number, + event: { + days: string, + start: number, + end: number + }, + building: string, + room: string + }[], + peerTeachers: { + id: number, + firstname: string, + lastname: string, + events: { + days: string, + start: number, + end: number + }[], + labs: number[] + }[] +} + +/** + * Parses a peer teacher schedule + * @param schedule The schedule to parse + * @returns A peer teacher + */ +export function parsePTSchedule(schedule: string) { + // namePatter: <firstname> <lastname> <uin> + const namePattern = /^(.*)\s(.*)\s(\d{9})/; + // eventPattern (24hr time): MTWRF hh:mm - hh:mm + const eventPattern = /^(M?T?W?R?F?)\s(\d{1,2}:\d{2})\s?-\s?(\d{1,2}:\d{2})/; + const lines = schedule.split("\n").filter(line => line.trim()); + + const nameLine = lines.find(line => line.match(namePattern)); + if(nameLine === undefined) { + throw new PeerTeacherImportError(`No peer teacher in schedule`); + } + + const [, firstname, lastname, uin] = nameLine.match(namePattern) as RegExpMatchArray; + const peerTeacher = new PeerTeacher(uin, firstname, lastname); + + const events = lines + .filter(line => line.match(eventPattern)) + .map(line => { + let [, days, start, end] = line.match(eventPattern) as RegExpMatchArray; + start = start.replace(":", ""); + end = end.replace(":", ""); + return new EventInfo(days, start, end); + }); + + peerTeacher.events = events; + return peerTeacher; +} + +/** + * Parses the course schedule into labs attended by PTs + * @param schedule The course schedule object from Howdy + * @returns An array of labs + */ +export function parseLabSchedule(schedule: LabSchedule) { + const taughtCourses = ['110', '111', '121', '206', '221', '312', '313', '315']; + const results: Lab[] = []; + + const courses = schedule.data; + for(const course of courses) { + if(!taughtCourses.includes(course.courseNumber)) { + continue; + } + + for(const meeting of course.meetingsFaculty) { + const { meetingTime } = meeting; + + if(meetingTime.meetingType !== "LAB") { + continue; + } + + let days = ""; + days += meetingTime.monday ? 'M' : ''; + days += meetingTime.tuesday ? 'T' : ''; + days += meetingTime.wednesday ? 'W' : ''; + days += meetingTime.thursday ? 'R' : ''; + days += meetingTime.friday ? 'F' : ''; + + const start = meetingTime.beginTime === null ? -1 : meetingTime.beginTime; + const end = meetingTime.endTime === null ? -1 : meetingTime.endTime; + const { courseNumber, sequenceNumber } = course; + const { building, room} = meetingTime; + + results.push( + new Lab( + courseNumber, + sequenceNumber, + new EventInfo(days, start, end), + building, + room + ) + ); + } + } + + return results; +} + +/** + * Parses a database file into maps of Lab and PeerTeacher objects + * @param database The database object from a database file + * @returns And object with lab and peer teacher maps + */ +export function parseDatabase(database: DatabaseFile) { + const result = { + labs: new Map<number, Lab>(), + peerTeachers: new Map<number, PeerTeacher>() + } + + database.labs.forEach(lab => { + result.labs.set(lab.id, Lab.fromJSON(lab)); + }); + + database.peerTeachers.forEach(pt => { + result.peerTeachers.set(pt.id, PeerTeacher.fromJSON(pt)); + }); + + return result; +}
\ No newline at end of file diff --git a/src/views/Editor.vue b/src/views/Editor.vue deleted file mode 100644 index 795ee9d..0000000 --- a/src/views/Editor.vue +++ /dev/null @@ -1,44 +0,0 @@ -<template> - <div id="editor"> - <ActionBar /> - <EditorLists :peerTeachers="peerTeachers" :labs="labs" /> - </div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import ActionBar from '@/components/ActionBar.vue'; -import EditorLists from '@/components/EditorLists.vue'; -import Lab from '@/models/Lab'; -import PeerTeacher from '@/models/PeerTeacher'; - -export default defineComponent({ - name: 'Editor', - components: { - ActionBar, - EditorLists, - }, - computed: { - labs(): Lab[] { - return Array.from(this.$store.state.labs.values()).sort((a, b) => a.id - .localeCompare(b.id)); - }, - peerTeachers(): PeerTeacher[] { - return Array.from(this.$store.state.peerTeachers.values()).sort((a, b) => a.lastname - .toUpperCase().localeCompare(b.lastname.toUpperCase())); - }, - }, -}); -</script> - -<style> -#editor { - display: flex; - flex-direction: column; - max-height: inherit; -} - -#action-bar { - flex-shrink: 0; -} -</style> diff --git a/src/views/Start.vue b/src/views/Start.vue deleted file mode 100644 index f32c337..0000000 --- a/src/views/Start.vue +++ /dev/null @@ -1,52 +0,0 @@ -<template> - <div id="start"> - <router-link to="/editor"> - <UIButton>Create new database</UIButton> - </router-link> - <FileUpload - :accept="'application/json'" - @fileChanged="handleDatabaseChange">Use existing database</FileUpload> - </div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import FileUpload from '@/components/FileUpload.vue'; -import { parsePtDatabase } from '@/features/parser'; -import UIButton from '@/components/UIButton.vue'; - -export default defineComponent({ - name: 'Start', - components: { - FileUpload, - UIButton, - }, - methods: { - async handleDatabaseChange(files: File[]) { - const result = await parsePtDatabase(files[0]); - this.$store.commit('setLabs', result.labs); - this.$store.commit('setPeerTeachers', result.peerTeachers); - this.$router.push({ name: 'Editor' }); - }, - }, -}); -</script> - -<style> -#start { - align-items: center; - display: flex; - flex-direction: column; - font-size: 1.5rem; - height: 100vh; - justify-content: center; -} - -#start > * { - margin-top: 1em; -} - -#start > *:first-child { - margin-top: 0; -} -</style> diff --git a/src/vuex-shim.d.ts b/src/vuex-shim.d.ts deleted file mode 100644 index c8a55f9..0000000 --- a/src/vuex-shim.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Store } from 'vuex'; - -import PeerTeacher from './models/PeerTeacher'; -import Lab from './models/Lab'; - -declare module '@vue/runtime-core' { - // declare your own store states - interface State { - peerTeachers: Map<number, PeerTeacher>, - labs: Map<string, Lab> - } - - // provide typings for `this.$store` - interface ComponentCustomProperties { - $store: Store<State> - } -} |
