Home Identifier Source Repository

src/lib/load-data.js

import map from 'lodash/collection/map'
import filter from 'lodash/collection/filter'
import size from 'lodash/collection/size'
import startsWith from 'lodash/string/startsWith'
import present from 'present'
import yaml from 'js-yaml'

import {status, json, text} from './fetch-helpers'
import db from './db'

import buildDept from '../helpers/build-dept'
import buildDeptNum from '../helpers/build-dept-num'
import splitParagraph from '../helpers/split-paragraph'
import {convertTimeStringsToOfferings} from 'sto-sis-time-parser'
import {
	logError,
	startProgress,
	incrementProgress,
	removeNotification,
} from '../ducks/notifications'
import {refreshData} from '../flux/student-actions'

import debug from 'debug'
let log = debug('gobbldygook:data')
debug.disable('gobbldygook:data')

import uniq from 'lodash/array/uniq'
import sortBy from 'lodash/collection/sortBy'
import flatten from 'lodash/array/flatten'

function dispatch(action) {
	typeof window !== 'undefined' && window.dispatch && window.dispatch(action)
}

function prepareCourse(course) {
	course.name = course.name || course.title
	course.dept = course.dept || buildDept(course)
	course.deptnum = course.deptnum || buildDeptNum(course)
	course.offerings = course.offerings || convertTimeStringsToOfferings(course)

	const nameWords = splitParagraph(course.name)
	const notesWords = splitParagraph(course.notes)
	const titleWords = splitParagraph(course.title)
	const descWords = splitParagraph(course.desc)

	course.words = uniq(sortBy([...nameWords, ...notesWords, ...titleWords, ...descWords]), true)
	course.profWords = uniq(sortBy(flatten(map(course.instructors, splitParagraph))), true)

	return course
}

function getCacheStoreName(type) {
	if (type === 'courses') {
		return 'courseCache'
	}
	else if (type === 'areas') {
		return 'areaCache'
	}
	else {
		throw new TypeError(`needsUpdate(): "${type}" is not a valid store type`)
	}
}

function storeCourses(item) {
	log(`storeCourses(): ${item.path}`)

	let coursesToStore = map(item.data, course => {
		course.sourcePath = item.path
		return prepareCourse(course)
	})

	const start = present()
	return db.store('courses').batch(coursesToStore)
		.then(() => {
			log(`Stored ${size(coursesToStore)} courses in ${present() - start}ms.`)
		})
		.catch(err => {
			const db = err.target.db.name
			const errorName = err.target.error.name

			if (errorName === 'QuotaExceededError') {
				dispatch(logError({
					id: 'db-storage-quota-exceeded',
					message: `The database "${db}" has exceeded its storage quota.`,
				}))
			}

			console.error(err)
			throw err

		})
}

function storeArea(item) {
	log(`storeArea(): ${item.path}`)

	const id = item.path

	let area = {
		...item.data,
		sourcePath: id,
		type: item.data.type.toLowerCase(),
	}

	return db.store('areas').put(area)
		.catch(err => {
			console.error(err)
			throw err
		})
}

function storeItem(item) {
	if (item.type === 'courses') {
		return storeCourses(item)
	}
	else if (item.type === 'areas') {
		return storeArea(item)
	}
}

async function cleanPriorData({path, type}) {
	log(`cleanPriorData(): ${path}`)

	let oldItems = []

	try {
		if (type === 'courses') {
			oldItems = await db.store(type).index('sourcePath').get(path)
			oldItems = map(oldItems, item => ({ [item.clbid]: null }))
		}
		else if (type === 'areas') {
			oldItems = await db.store(type).get(path)
			oldItems = map(oldItems, item => ({ [item.sourcePath]: null }))
		}
		else {
			throw new TypeError(`needsUpdate(): "${type}" is not a valid store type`)
		}

		await db.store(type).batch(oldItems)
	}
	catch (err) {
		throw err
	}

	try {
		await db.store(getCacheStoreName(type)).del(path)
	}
	catch (err) {
		throw err
	}
}

function cacheItemHash({path, type, hash}) {
	log(`cacheItemHash(): ${path}`)

	return db.store(getCacheStoreName(type)).put({id: path, path, hash})
}

async function updateDatabase(type, infoFromServer, infoFileBase, notificationId, count) {
	const {path, hash} = infoFromServer
	const itemUrl = `/${path}?v=${hash}`

	log(`updateDatabase(): ${path}`)
	dispatch(startProgress(notificationId, `Loading ${type}`, {max: count}, true))

	const url = infoFileBase + itemUrl
	let data = undefined
	try {
		data = await fetch(url)
			.then(status)
			.then(text)
	}
	catch (err) {
		console.error('Could not fetch ${url}')
		return false
	}

	if (type === 'courses') {
		data = JSON.parse(data)
	}
	else if (type === 'areas') {
		data = {...yaml.safeLoad(data), source: data}
	}

	const item = {
		...infoFromServer,
		path,
		hash,
		data,
		type,
		count: size(data),
	}

	try {
		await cleanPriorData(item)
		await storeItem(item)
		await cacheItemHash(item)
	}
	catch (e) {
		throw e
	}

	log(`added ${item.path} (${item.count} ${item.type})`)
	dispatch(incrementProgress(notificationId))
}

async function needsUpdate({type, path, hash}) {
	const {hash: oldHash} = await db.store(getCacheStoreName(type)).get(path) || {}

	return hash !== oldHash
}

async function loadDataFiles(infoFile, infoFileBase) {
	log(`loadDataFiles(): ${infoFileBase}`)

	const notificationId = infoFile.type
	let filesToLoad = infoFile.files

	if (infoFile.type === 'courses') {
		// Only get the last four years of data
		const oldestYear = new Date().getFullYear() - 4
		filesToLoad = filter(filesToLoad, file => file.year >= oldestYear)
	}

	const fileNeedsLoading = await* map(filesToLoad, file => needsUpdate({type: infoFile.type, path: file.path, hash: file.hash}))
	filesToLoad = filter(filesToLoad, (file, index) => fileNeedsLoading[index])

	// Load them into the database
	try {
		await* map(filesToLoad, file => updateDatabase(infoFile.type, file, infoFileBase, notificationId, size(filesToLoad)))
	}
	catch (err) {
		throw err
	}

	dispatch(removeNotification(notificationId, {delay: 1500}))

	return infoFile
}

function loadInfoFile(url, infoFileBase) {
	log(`loadInfoFile(): ${url}`)

	return fetch(url)
		.then(status)
		.then(json)
		.then(infoFile => loadDataFiles(infoFile, infoFileBase))
		.then(infoFile => {
			refreshData({[infoFile.type]: true})
		})
		.catch(err => {
			if (startsWith(err.message, 'Failed to fetch')) {
				console.error(`loadInfoFile(): Failed to fetch ${url}`)
				return false
			}
			else {
				throw err
			}
		})
}

export default function loadData() {
	const cachebuster = Date.now()

	const infoFiles = [
		'./courseData.url',
		'./areaData.url',
	]

	const promises = map(infoFiles,
		file => fetch(file)
			.then(status)
			.then(text)
			.then(path => path.trim())
			.then(path => {
				return loadInfoFile(`${path}/info.json?${cachebuster}`, path)
			})
			.catch(err => {
				throw err
			}))

	return Promise.all(promises)
}