From 7dbffc6eef346747042f1596da627ad0a2fcae1a Mon Sep 17 00:00:00 2001 From: Derek Chen Date: Thu, 21 Nov 2024 12:55:48 -0600 Subject: [PATCH] feat: export/import functionality (backup/restore/share with friends) + a new input component (#433) * feat: export schedule function to be added to handler * feat: use UserScheduleStore and return json * feat: download functionality * feat: oh wow we already have a blob download util that is very very nice * feat: return empty json if none found * feat: import function completion * feat: file uploading done * feat: new input component-stories made-settings input replaced with component * feat: attempt 1 to hook settings.tsx to importSchedule * feat: it works horray aka using right Course constructor it works * chore: fix jsdoc * chore: comments and debug style * docs: extra comment * feat: name of schedule more user friendly * feat: reworked how schedule is passed and check for file being schedule * feat: color is kept on import * fix: add sendResponse to exportSchedule --------- Co-authored-by: doprz <52579214+doprz@users.noreply.github.com> --- .../background/handler/userScheduleHandler.ts | 4 + src/pages/background/lib/addCourse.ts | 8 +- src/pages/background/lib/exportSchedule.ts | 24 ++++ src/pages/background/lib/importSchedule.ts | 35 ++++++ src/shared/messages/UserScheduleMessages.ts | 8 ++ .../components/InputButton.stories.tsx | 109 ++++++++++++++++++ src/views/components/common/InputButton.tsx | 74 ++++++++++++ src/views/components/settings/Settings.tsx | 69 +++++++++++ 8 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 src/pages/background/lib/exportSchedule.ts create mode 100644 src/pages/background/lib/importSchedule.ts create mode 100644 src/stories/components/InputButton.stories.tsx create mode 100644 src/views/components/common/InputButton.tsx diff --git a/src/pages/background/handler/userScheduleHandler.ts b/src/pages/background/handler/userScheduleHandler.ts index cce4be87..28d58579 100644 --- a/src/pages/background/handler/userScheduleHandler.ts +++ b/src/pages/background/handler/userScheduleHandler.ts @@ -2,6 +2,7 @@ import addCourse from '@pages/background/lib/addCourse'; import clearCourses from '@pages/background/lib/clearCourses'; import createSchedule from '@pages/background/lib/createSchedule'; import deleteSchedule from '@pages/background/lib/deleteSchedule'; +import exportSchedule from '@pages/background/lib/exportSchedule'; import removeCourse from '@pages/background/lib/removeCourse'; import renameSchedule from '@pages/background/lib/renameSchedule'; import switchSchedule from '@pages/background/lib/switchSchedule'; @@ -40,6 +41,9 @@ const userScheduleHandler: MessageHandler = { .then(res => (response === 'json' ? res.json() : res.text())) .then(sendResponse); }, + exportSchedule({ data, sendResponse }) { + exportSchedule(data.scheduleId).then(sendResponse); + }, }; export default userScheduleHandler; diff --git a/src/pages/background/lib/addCourse.ts b/src/pages/background/lib/addCourse.ts index 8676e212..9a14087d 100644 --- a/src/pages/background/lib/addCourse.ts +++ b/src/pages/background/lib/addCourse.ts @@ -4,19 +4,23 @@ import { getUnusedColor } from '@shared/util/colors'; /** * Adds a course to a user's schedule. + * * @param scheduleId - The id of the schedule to add the course to. * @param course - The course to add. + * @param hasColor - If the course block already has colors manually set * @returns A promise that resolves to void. * @throws An error if the schedule is not found. */ -export default async function addCourse(scheduleId: string, course: Course): Promise { +export default async function addCourse(scheduleId: string, course: Course, hasColor = false): Promise { const schedules = await UserScheduleStore.get('schedules'); const activeSchedule = schedules.find(s => s.id === scheduleId); if (!activeSchedule) { throw new Error('Schedule not found'); } - course.colors = getUnusedColor(activeSchedule, course); + if (!hasColor) { + course.colors = getUnusedColor(activeSchedule, course); + } activeSchedule.courses.push(course); activeSchedule.updatedAt = Date.now(); await UserScheduleStore.set('schedules', schedules); diff --git a/src/pages/background/lib/exportSchedule.ts b/src/pages/background/lib/exportSchedule.ts new file mode 100644 index 00000000..64157671 --- /dev/null +++ b/src/pages/background/lib/exportSchedule.ts @@ -0,0 +1,24 @@ +import { UserScheduleStore } from '@shared/storage/UserScheduleStore'; + +/** + * Exports the provided schedule to a portable JSON + * + * @param scheduleId - The Id matching the to-be-exported schedule + * @returns JSON format of the provided schedule ID, empty if one was not found + */ +export default async function exportSchedule(scheduleId: string): Promise { + try { + const storageData = await UserScheduleStore.get('schedules'); + const selectedSchedule = storageData.find(s => s.id === scheduleId); + + if (!selectedSchedule) { + console.warn(`Schedule ${scheduleId} does not exist`); + return JSON.stringify({}); + } + + console.log(selectedSchedule); + return JSON.stringify(selectedSchedule, null, 2); + } catch (error) { + console.error('Error getting storage data:', error); + } +} diff --git a/src/pages/background/lib/importSchedule.ts b/src/pages/background/lib/importSchedule.ts new file mode 100644 index 00000000..e85adca6 --- /dev/null +++ b/src/pages/background/lib/importSchedule.ts @@ -0,0 +1,35 @@ +import { Course } from '@shared/types/Course'; +import type { UserSchedule } from '@shared/types/UserSchedule'; +import type { Serialized } from 'chrome-extension-toolkit'; + +import addCourse from './addCourse'; +import createSchedule from './createSchedule'; +import switchSchedule from './switchSchedule'; + +function isValidSchedule(data: unknown): data is Serialized { + if (typeof data !== 'object' || data === null) return false; + const schedule = data as Record; + return typeof schedule.id === 'string' && typeof schedule.name === 'string' && Array.isArray(schedule.courses); +} + +/** + * Imports a user schedule from portable file, creating a new schedule for it + + * @param scheduleData - Data to be parsed back into a course schedule + */ +export default async function importSchedule(scheduleData: unknown): Promise { + if (isValidSchedule(scheduleData)) { + const newScheduleId = await createSchedule(scheduleData.name); + await switchSchedule(newScheduleId); + + for (const c of scheduleData.courses) { + const course = new Course(c); + // eslint-disable-next-line no-await-in-loop + await addCourse(newScheduleId, course, true); + console.log(course.colors); + } + console.log('Course schedule successfully parsed!'); + } else { + console.error('No schedule data provided for import'); + } +} diff --git a/src/shared/messages/UserScheduleMessages.ts b/src/shared/messages/UserScheduleMessages.ts index 0416bf63..53b8f372 100644 --- a/src/shared/messages/UserScheduleMessages.ts +++ b/src/shared/messages/UserScheduleMessages.ts @@ -63,4 +63,12 @@ export interface UserScheduleMessages { * @returns Undefined if successful, otherwise an error message */ renameSchedule: (data: { scheduleId: string; newName: string }) => string | undefined; + + /** + * Exports the current schedule to a JSON file for backing up and sharing + * + * @param data - Id of schedule that will be exported + * @returns + */ + exportSchedule: (data: { scheduleId: string }) => string | undefined; } diff --git a/src/stories/components/InputButton.stories.tsx b/src/stories/components/InputButton.stories.tsx new file mode 100644 index 00000000..a0188081 --- /dev/null +++ b/src/stories/components/InputButton.stories.tsx @@ -0,0 +1,109 @@ +import { colorsFlattened } from '@shared/util/themeColors'; +import type { Meta, StoryObj } from '@storybook/react'; +import InputButton from '@views/components/common/InputButton'; +import React from 'react'; + +import AddIcon from '~icons/material-symbols/add'; +import CalendarMonthIcon from '~icons/material-symbols/calendar-month'; +import DescriptionIcon from '~icons/material-symbols/description'; +import ImagePlaceholderIcon from '~icons/material-symbols/image'; +import HappyFaceIcon from '~icons/material-symbols/mood'; +import ReviewsIcon from '~icons/material-symbols/reviews'; + +/** + * Stole this straight from Button.stories.tsx to test the input + */ +const meta = { + title: 'Components/Common/InputButton', + component: InputButton, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + args: { + children: 'Upload File', + icon: ImagePlaceholderIcon, + }, + argTypes: { + children: { control: 'text' }, + color: { + control: 'select', + options: Object.keys(colorsFlattened), + }, + variant: { + control: 'select', + options: ['filled', 'outline', 'single'], + }, + disabled: { + control: 'boolean', + }, + onChange: { action: 'file selected' }, // action to show when file is selected + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + variant: 'filled', + color: 'ut-black', // Default theme color + }, +}; + +export const Disabled: Story = { + args: { + variant: 'filled', + color: 'ut-black', + disabled: true, + }, +}; + +// @ts-ignore +export const Grid: Story = { + render: props => ( +
+
+ + + +
+ +
+ +
+ + + +
+
+ ), +}; + +export const PrettyColors: Story = { + // @ts-ignore + args: { + children: '', + }, + render: props => { + const colorsNames = Object.keys(colorsFlattened) as (keyof typeof colorsFlattened)[]; + + return ( +
+ {colorsNames.map(color => ( +
+ + Button + + + Button + + + Button + +
+ ))} +
+ ); + }, +}; diff --git a/src/views/components/common/InputButton.tsx b/src/views/components/common/InputButton.tsx new file mode 100644 index 00000000..63fcdf97 --- /dev/null +++ b/src/views/components/common/InputButton.tsx @@ -0,0 +1,74 @@ +import type { ThemeColor } from '@shared/types/ThemeColors'; +import { getThemeColorHexByName, getThemeColorRgbByName } from '@shared/util/themeColors'; +import Text from '@views/components/common/Text/Text'; +import clsx from 'clsx'; +import React from 'react'; + +import type IconComponent from '~icons/material-symbols'; + +interface Props { + className?: string; + style?: React.CSSProperties; + variant: 'filled' | 'outline' | 'single'; + onChange?: (event: React.ChangeEvent) => void; + icon?: typeof IconComponent; + disabled?: boolean; + title?: string; + color: ThemeColor; +} + +/** + * A reusable input button component that follows the Button.tsx consistency + * + * @returns + */ +export default function InputButton({ + className, + style, + variant, + onChange, + icon, + disabled, + title, + color, + children, +}: React.PropsWithChildren): JSX.Element { + const Icon = icon; + const isIconOnly = !children && !!icon; + const colorHex = getThemeColorHexByName(color); + const colorRgb = getThemeColorRgbByName(color)?.join(' '); + + return ( + + ); +} diff --git a/src/views/components/settings/Settings.tsx b/src/views/components/settings/Settings.tsx index de16fc45..c92a3d08 100644 --- a/src/views/components/settings/Settings.tsx +++ b/src/views/components/settings/Settings.tsx @@ -1,7 +1,12 @@ // import addCourse from '@pages/background/lib/addCourse'; import { addCourseByURL } from '@pages/background/lib/addCourseByURL'; import { deleteAllSchedules } from '@pages/background/lib/deleteSchedule'; +import exportSchedule from '@pages/background/lib/exportSchedule'; +import importSchedule from '@pages/background/lib/importSchedule'; +import { background } from '@shared/messages'; import { initSettings, OptionsStore } from '@shared/storage/OptionsStore'; +import { UserScheduleStore } from '@shared/storage/UserScheduleStore'; +import { downloadBlob } from '@shared/util/downloadBlob'; // import { addCourseByUrl } from '@shared/util/courseUtils'; // import { getCourseColors } from '@shared/util/colors'; // import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell'; @@ -26,6 +31,7 @@ import IconoirGitFork from '~icons/iconoir/git-fork'; // import { ExampleCourse } from 'src/stories/components/ConflictsWithWarning.stories'; import DeleteForeverIcon from '~icons/material-symbols/delete-forever'; +import InputButton from '../common/InputButton'; import { useMigrationDialog } from '../common/MigrationDialog'; // import RefreshIcon from '~icons/material-symbols/refresh'; import DevMode from './DevMode'; @@ -204,6 +210,35 @@ export default function Settings(): JSX.Element { }); }; + const handleExportClick = async (id: string) => { + const jsonString = await exportSchedule(id); + if (jsonString) { + const schedules = await UserScheduleStore.get('schedules'); + const schedule = schedules.find(s => s.id === id); + const fileName = `${schedule?.name ?? `schedule_${id}`}_${new Date().toISOString().replace(/[:.]/g, '-')}.json`; + await downloadBlob(jsonString, 'JSON', fileName); + } else { + console.error('Error exporting schedule: jsonString is undefined'); + } + }; + + const handleImportClick = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = async e => { + try { + const result = e.target?.result as string; + const jsonObject = JSON.parse(result); + await importSchedule(jsonObject); + } catch (error) { + console.error('Invalid import file!'); + } + }; + reader.readAsText(file); + } + }; + // const handleAddCourseByLink = async () => { // // todo: Use a proper modal instead of a prompt // const link: string | null = prompt('Enter course link'); @@ -323,6 +358,40 @@ export default function Settings(): JSX.Element { */} +
+
+ + Export Current Schedule + +

+ Backup your active schedule to a portable file +

+
+ +
+ + + +
+
+ + Import Schedule + +

Import from a schedule file

+
+ + Import Schedule + +
+ + +