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>
This commit is contained in:
@@ -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<UserScheduleMessages> = {
|
||||
.then(res => (response === 'json' ? res.json() : res.text()))
|
||||
.then(sendResponse);
|
||||
},
|
||||
exportSchedule({ data, sendResponse }) {
|
||||
exportSchedule(data.scheduleId).then(sendResponse);
|
||||
},
|
||||
};
|
||||
|
||||
export default userScheduleHandler;
|
||||
|
||||
@@ -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<void> {
|
||||
export default async function addCourse(scheduleId: string, course: Course, hasColor = false): Promise<void> {
|
||||
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);
|
||||
|
||||
24
src/pages/background/lib/exportSchedule.ts
Normal file
24
src/pages/background/lib/exportSchedule.ts
Normal file
@@ -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<string | undefined> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
35
src/pages/background/lib/importSchedule.ts
Normal file
35
src/pages/background/lib/importSchedule.ts
Normal file
@@ -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<UserSchedule> {
|
||||
if (typeof data !== 'object' || data === null) return false;
|
||||
const schedule = data as Record<string, unknown>;
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
109
src/stories/components/InputButton.stories.tsx
Normal file
109
src/stories/components/InputButton.stories.tsx
Normal file
@@ -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<typeof InputButton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
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 => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
|
||||
<div style={{ display: 'flex', gap: '15px' }}>
|
||||
<InputButton {...props} variant='filled' color='ut-black' />
|
||||
<InputButton {...props} variant='outline' color='ut-black' />
|
||||
<InputButton {...props} variant='single' color='ut-black' />
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div style={{ display: 'flex', gap: '15px' }}>
|
||||
<InputButton {...props} variant='filled' color='ut-black' disabled />
|
||||
<InputButton {...props} variant='outline' color='ut-black' disabled />
|
||||
<InputButton {...props} variant='single' color='ut-black' disabled />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const PrettyColors: Story = {
|
||||
// @ts-ignore
|
||||
args: {
|
||||
children: '',
|
||||
},
|
||||
render: props => {
|
||||
const colorsNames = Object.keys(colorsFlattened) as (keyof typeof colorsFlattened)[];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
|
||||
{colorsNames.map(color => (
|
||||
<div style={{ display: 'flex', gap: '15px' }} key={color}>
|
||||
<InputButton {...props} variant='filled' color={color}>
|
||||
Button
|
||||
</InputButton>
|
||||
<InputButton {...props} variant='outline' color={color}>
|
||||
Button
|
||||
</InputButton>
|
||||
<InputButton {...props} variant='single' color={color}>
|
||||
Button
|
||||
</InputButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
74
src/views/components/common/InputButton.tsx
Normal file
74
src/views/components/common/InputButton.tsx
Normal file
@@ -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<HTMLInputElement>) => 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<Props>): JSX.Element {
|
||||
const Icon = icon;
|
||||
const isIconOnly = !children && !!icon;
|
||||
const colorHex = getThemeColorHexByName(color);
|
||||
const colorRgb = getThemeColorRgbByName(color)?.join(' ');
|
||||
|
||||
return (
|
||||
<label
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
color: disabled ? 'ut-gray' : colorHex,
|
||||
backgroundColor: `rgb(${colorRgb} / var(--un-bg-opacity)`,
|
||||
} satisfies React.CSSProperties
|
||||
}
|
||||
className={clsx(
|
||||
'btn',
|
||||
{
|
||||
'text-white! bg-opacity-100 hover:enabled:shadow-md active:enabled:shadow-sm shadow-black/20':
|
||||
variant === 'filled',
|
||||
'bg-opacity-0 border-current hover:enabled:bg-opacity-8 border': variant === 'outline',
|
||||
'bg-opacity-0 border-none hover:enabled:bg-opacity-8': variant === 'single', // settings is the only "single"
|
||||
'px-2 py-1.25': isIconOnly && variant !== 'outline',
|
||||
'px-1.75 py-1.25': isIconOnly && variant === 'outline',
|
||||
'px-3.75': variant === 'outline' && !isIconOnly,
|
||||
},
|
||||
className
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
{Icon && <Icon className='h-6 w-6' />}
|
||||
{!isIconOnly && (
|
||||
<Text variant='h4' className='inline-flex translate-y-0.08 items-center gap-2'>
|
||||
{children}
|
||||
</Text>
|
||||
)}
|
||||
<input type='file' className='hidden' disabled={disabled} onChange={disabled ? undefined : onChange} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
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 {
|
||||
|
||||
<Divider size='auto' orientation='horizontal' /> */}
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='max-w-xs'>
|
||||
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
||||
Export Current Schedule
|
||||
</Text>
|
||||
<p className='text-sm text-gray-600'>
|
||||
Backup your active schedule to a portable file
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='ut-burntorange'
|
||||
onClick={() => handleExportClick(activeSchedule.id)}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Divider size='auto' orientation='horizontal' />
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='max-w-xs'>
|
||||
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
||||
Import Schedule
|
||||
</Text>
|
||||
<p className='text-sm text-gray-600'>Import from a schedule file</p>
|
||||
</div>
|
||||
<InputButton variant='filled' color='ut-burntorange' onChange={handleImportClick}>
|
||||
Import Schedule
|
||||
</InputButton>
|
||||
</div>
|
||||
|
||||
<Divider size='auto' orientation='horizontal' />
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='max-w-xs'>
|
||||
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
||||
|
||||
Reference in New Issue
Block a user