@wyteco/berkeley-ical
Version:
A command line tool to easily export your classes from the Berkeley Academic Guide to your calendar in iCal (.ics) format. Without having a student account lol!
190 lines (156 loc) • 5.26 kB
text/typescript
import fs from 'fs';
import { Command } from 'commander';
import cliProgress from 'cli-progress';
import figlet from 'figlet';
import { z } from 'zod';
//
import { generateIcsEvent } from './ics';
import { fetchAndParseCourseData } from './parse';
// ----------------------------------------------------------------------
const program = new Command();
console.log(figlet.textSync('Berkeley iCal'));
console.log('');
program
.version('1.0.0')
.description(
'A command line tool to easily export your classes from the Berkeley Academic Guide to your calendar in iCal (.ics) format. Without having a student account lol!'
)
.option('-v, --verbose', 'Enable verbose mode')
.option(
'-o, --output <path>',
'Specify output path for the .ics file (default: current directory)',
'.'
)
.arguments('<urls...>')
.action((urls) => {
return urls;
})
.parse(process.argv);
/**
* This stores all the unvalidated options provided by the user.
*/
const programOptions = program.opts();
const isVerbose = !!programOptions.verbose;
if (isVerbose) {
console.log('Verbose mode enabled.');
console.log('');
}
if (isVerbose) {
console.log('options', programOptions);
console.log('');
}
/**
* This stores all the unvalidated URLs provided by the user.
*/
const programArguments = program.args;
if (isVerbose) {
console.log('arguments', programArguments);
console.log('');
}
// ----------------------------------------------------------------------
/**
* Validate the output path provided by the user.
* Note that the default output path is the current directory `.`.
*/
if (!fs.existsSync(programOptions.output)) {
console.log(
`Output path does not exist. Creating directory: "${programOptions.output}".`
);
console.log('');
fs.mkdirSync(programOptions.output, { recursive: true });
}
if (!fs.statSync(programOptions.output).isDirectory()) {
console.error('The output path is not a valid directory!');
process.exit(1);
}
const validatedOutputPath = programOptions.output;
// ----------------------------------------------------------------------
/**
* Validate the URLs provided by the user.
* Every URL should be a valid URL, we do not just filter out the
* invalid ones.
*/
if (programArguments.length === 0) {
console.error('No URLs provided.');
process.exit(1);
}
const validatedUrls = programArguments.map((argument) => {
try {
return z.string().url().parse(argument);
} catch (error) {
console.error('Invalid URL provided:', argument);
process.exit(1);
}
});
// ----------------------------------------------------------------------
const run = async () => {
console.log('Fetching and parsing course data...');
console.log(
'This may take a while depending on the number of URLs provided.'
);
console.log('');
const progressBar = new cliProgress.SingleBar(
{
format: 'Progress [{bar}] {percentage}% | {value}/{total} URLs',
},
cliProgress.Presets.shades_classic
);
progressBar.start(validatedUrls.length, 0);
/**
* Fetch and parse the course data from the provided URLs.
*/
const courses = await Promise.all(
validatedUrls.map(async (url) => {
const course = await fetchAndParseCourseData(url);
progressBar.increment();
return course;
})
);
progressBar.stop();
console.log('');
console.log('Fetched and parsed course data of all the URLs.');
console.log(
`There is ${courses.length} course${courses.length === 1 ? '' : 's'}.`
);
console.log('');
if (isVerbose) {
console.log('courses', courses);
console.log('');
}
// ----------------------------------------------------------------------
/**
* Generate the iCal (.ics) file from the parsed course data.
*/
const icsEventStrings = courses.map((course) => generateIcsEvent(course));
const icsFileString = `BEGIN:VCALENDAR\nVERSION:2.0\n${icsEventStrings.join(
'\n'
)}\nEND:VCALENDAR`;
// ----------------------------------------------------------------------
/**
* Write the iCal (.ics) file to the output path.
* We append the current timestamp to the filename to ensure that the program
* can be run multiple times without overwriting the existing file.
* The timestamp is in the format "YYYY-MM-DDTHH-mm-ss" and generated based on ISO.
*/
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
let icsFilePath = `${validatedOutputPath}/berkeley-classes-${timestamp}.ics`;
/**
* This might be overengineering, but we'll add a counter to the filename
* if the file already exists. This is to avoid overwriting the existing file.
* We could also just throw an error, but this is more user-friendly.
*/
let filePathCounter = 1;
while (fs.existsSync(icsFilePath)) {
console.log(
`The file "${icsFilePath}" already exists. Incrementing the counter to avoid overwriting the existing file...`
);
icsFilePath = `${validatedOutputPath}/berkeley-classes-${timestamp}-${filePathCounter}.ics`;
filePathCounter++;
}
fs.writeFileSync(icsFilePath, icsFileString);
console.log(`iCal file generated at "${icsFilePath}".`);
console.log('Done!');
process.exit(0);
};
run();