diff --git a/external/idb-keyval/README.md b/external/idb-keyval/README.md new file mode 100644 index 0000000..7252276 --- /dev/null +++ b/external/idb-keyval/README.md @@ -0,0 +1 @@ +IDB-Keyval from https://www.npmjs.com/package/idb-keyval Apache-2.0 licensed. diff --git a/external/idb-keyval/index.js b/external/idb-keyval/index.js new file mode 100644 index 0000000..5da144a --- /dev/null +++ b/external/idb-keyval/index.js @@ -0,0 +1,184 @@ +function promisifyRequest(request) { + return new Promise((resolve, reject) => { + // @ts-ignore - file size hacks + request.oncomplete = request.onsuccess = () => resolve(request.result); + // @ts-ignore - file size hacks + request.onabort = request.onerror = () => reject(request.error); + }); +} +function createStore(dbName, storeName) { + const request = indexedDB.open(dbName); + request.onupgradeneeded = () => request.result.createObjectStore(storeName); + const dbp = promisifyRequest(request); + return (txMode, callback) => dbp.then((db) => callback(db.transaction(storeName, txMode).objectStore(storeName))); +} +let defaultGetStoreFunc; +function defaultGetStore() { + if (!defaultGetStoreFunc) { + defaultGetStoreFunc = createStore('keyval-store', 'keyval'); + } + return defaultGetStoreFunc; +} +/** + * Get a value by its key. + * + * @param key + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ +function get(key, customStore = defaultGetStore()) { + return customStore('readonly', (store) => promisifyRequest(store.get(key))); +} +/** + * Set a value with a key. + * + * @param key + * @param value + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ +function set(key, value, customStore = defaultGetStore()) { + return customStore('readwrite', (store) => { + store.put(value, key); + return promisifyRequest(store.transaction); + }); +} +/** + * Set multiple values at once. This is faster than calling set() multiple times. + * It's also atomic – if one of the pairs can't be added, none will be added. + * + * @param entries Array of entries, where each entry is an array of `[key, value]`. + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ +function setMany(entries, customStore = defaultGetStore()) { + return customStore('readwrite', (store) => { + entries.forEach((entry) => store.put(entry[1], entry[0])); + return promisifyRequest(store.transaction); + }); +} +/** + * Get multiple values by their keys + * + * @param keys + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ +function getMany(keys, customStore = defaultGetStore()) { + return customStore('readonly', (store) => Promise.all(keys.map((key) => promisifyRequest(store.get(key))))); +} +/** + * Update a value. This lets you see the old value and update it as an atomic operation. + * + * @param key + * @param updater A callback that takes the old value and returns a new value. + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ +function update(key, updater, customStore = defaultGetStore()) { + return customStore('readwrite', (store) => + // Need to create the promise manually. + // If I try to chain promises, the transaction closes in browsers + // that use a promise polyfill (IE10/11). + new Promise((resolve, reject) => { + store.get(key).onsuccess = function () { + try { + store.put(updater(this.result), key); + resolve(promisifyRequest(store.transaction)); + } + catch (err) { + reject(err); + } + }; + })); +} +/** + * Delete a particular key from the store. + * + * @param key + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ +function del(key, customStore = defaultGetStore()) { + return customStore('readwrite', (store) => { + store.delete(key); + return promisifyRequest(store.transaction); + }); +} +/** + * Delete multiple keys at once. + * + * @param keys List of keys to delete. + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ +function delMany(keys, customStore = defaultGetStore()) { + return customStore('readwrite', (store) => { + keys.forEach((key) => store.delete(key)); + return promisifyRequest(store.transaction); + }); +} +/** + * Clear all values in the store. + * + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ +function clear(customStore = defaultGetStore()) { + return customStore('readwrite', (store) => { + store.clear(); + return promisifyRequest(store.transaction); + }); +} +function eachCursor(store, callback) { + store.openCursor().onsuccess = function () { + if (!this.result) + return; + callback(this.result); + this.result.continue(); + }; + return promisifyRequest(store.transaction); +} +/** + * Get all keys in the store. + * + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ +function keys(customStore = defaultGetStore()) { + return customStore('readonly', (store) => { + // Fast path for modern browsers + if (store.getAllKeys) { + return promisifyRequest(store.getAllKeys()); + } + const items = []; + return eachCursor(store, (cursor) => items.push(cursor.key)).then(() => items); + }); +} +/** + * Get all values in the store. + * + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ +function values(customStore = defaultGetStore()) { + return customStore('readonly', (store) => { + // Fast path for modern browsers + if (store.getAll) { + return promisifyRequest(store.getAll()); + } + const items = []; + return eachCursor(store, (cursor) => items.push(cursor.value)).then(() => items); + }); +} +/** + * Get all entries in the store. Each entry is an array of `[key, value]`. + * + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ +function entries(customStore = defaultGetStore()) { + return customStore('readonly', (store) => { + // Fast path for modern browsers + // (although, hopefully we'll get a simpler path some day) + if (store.getAll && store.getAllKeys) { + return Promise.all([ + promisifyRequest(store.getAllKeys()), + promisifyRequest(store.getAll()), + ]).then(([keys, values]) => keys.map((key, i) => [key, values[i]])); + } + const items = []; + return customStore('readonly', (store) => eachCursor(store, (cursor) => items.push([cursor.key, cursor.value])).then(() => items)); + }); +} + +export { clear, createStore, del, delMany, entries, get, getMany, keys, promisifyRequest, set, setMany, update, values }; diff --git a/www/app.js b/www/app.js index cfc2500..921e91a 100644 --- a/www/app.js +++ b/www/app.js @@ -1,4 +1,275 @@ +import { get as idb_get, + set as idb_set, + del as idb_del } from './idb-keyval.js'; import Schedule from './schedule.js'; +import * as utils from './utils.js'; -// XXX For debugging: -window.Schedule = Schedule; + +const openDirButton = document.getElementById('openDir'); +const reopenDirButton = document.getElementById('reopenDir'); +const closeDirButton = document.getElementById('closeDir'); +const schedulesDiv = document.getElementById('allSchedules'); +const scheduleSettingsDiv = document.getElementById('scheduleSettings'); + +let dirHandle = null; +let backupsDir = null; +let fileHandle = null; +let basefileHandle = null; +let schedule = null; + +function closeDir() { + dirHandle = null; + idb_del('dirHandle'); + openDirButton.style.display = ''; + closeDirButton.style.display = 'none'; + window.location.reload(); +} + +async function saveSchedule() { + if (fileHandle !== basefileHandle) { + alert("Cannot modify backups. This should not be reachable."); + return; + } + + schedule.lastModified = new Date(); + const writable = await basefileHandle.createWritable(); + await writable.write(JSON.stringify(schedule.asJsonObject(), null, 2)); + await writable.close(); + + refreshDisplay(); +} + +async function updateBackupsList() { + backupsDir = await dirHandle.getDirectoryHandle( + basefileHandle.name.slice(0, -5), {create: true}); + + const backupsList = document.getElementById('backups'); + utils.clearChildren(backupsList); + const options = []; + + for await (const entry of backupsDir.values()) { + if (entry.kind === 'file' && entry.name.endsWith('.json')) { + const fileObj = await entry.getFile() + const timestamp = fileObj.lastModified; + const option = document.createElement('option'); + option.innerText = entry.name.slice(0, -5) + " [" + + new Date(timestamp) + "]"; + option.timestamp = timestamp; + option.file = entry; + option.isBackup = true; + if (entry.name === fileHandle.name) option.selected = true; + options.push(option); + } + } + + options.sort((a, b) => b.timestamp - a.timestamp); + + const current = document.createElement('option'); + const currentTimestamp = (await basefileHandle.getFile()).lastModified; + current.innerText = "(latest) [" + new Date(currentTimestamp) + "]"; + current.timestamp = currentTimestamp; + current.file = basefileHandle; + current.isBackup = false; + options.unshift(current); + + options.forEach(opt => backupsList.appendChild(opt)); +} + +document.getElementById('createBackup').addEventListener('click', async e => { + let name = document.getElementById('backupName').value; + if (name.length === 0) { + name = new Date().toISOString().replaceAll(':', '-'); + } + + const file = await backupsDir.getFileHandle(name + '.json', {create: true}); + if ((await file.getFile()).size !== 0) { + alert("Failed to create backup. Backup \"" + name + "\" already exists."); + return; + } + + const writable = await file.createWritable(); + await writable.write(JSON.stringify(schedule.asJsonObject(), null, 2)); + await writable.close(); + + updateBackupsList(); +}); + +const titleInput = document.getElementById('title'); +let allowEdits = false; +async function updateScheduleSettings() { + updateBackupsList(); + titleInput.value = schedule.base_title; + titleInput.disabled = !allowEdits; + + document.getElementById('scheduleMetadata') + .querySelectorAll('*') + .forEach(el => el.disabled = !allowEdits); + + schedule.recomputeNotAssigned(); + + const staffList = document.getElementById('removeStaff'); + utils.clearChildren(staffList); + schedule.notAssignedStaff.forEach(name => { + const option = document.createElement('option'); + option.value = name; + option.innerText = name; + staffList.appendChild(option); + }); + const canDelStaff = allowEdits && schedule.notAssignedStaff.size > 0; + staffList.disabled = !canDelStaff; + document.getElementById('delStaff').disabled = !canDelStaff; + + const studentList = document.getElementById('removeStudent'); + utils.clearChildren(studentList); + schedule.notAssignedStudents.forEach(name => { + const option = document.createElement('option'); + option.value = name; + option.innerText = name; + studentList.appendChild(option); + }); + const canDelStudent = allowEdits && schedule.notAssignedStudents.size > 0; + studentList.disabled = !canDelStudent; + document.getElementById('delStudent').disabled = !canDelStudent; + + scheduleSettingsDiv.style.display = ''; +} + +async function loadFile(file, isBackup) { + fileHandle = file; + if (!isBackup) basefileHandle = fileHandle; + allowEdits = !isBackup; + const fileObj = await file.getFile(); + schedule = new Schedule(JSON.parse(await fileObj.text())); + schedule.lastModified = fileObj.lastModified; + updateScheduleSettings(); + refreshDisplay(); +} + +document.getElementById('schedules').addEventListener('change', async e => { + loadFile(e.target.selectedOptions[0].file, false); +}); + +document.getElementById('backups').addEventListener('change', async e => { + const opt = e.target.selectedOptions[0]; + loadFile(opt.file, opt.isBackup); +}); + +async function loadDir(dir) { + dirHandle = dir; + openDirButton.style.display = 'none'; + reopenDirButton.style.display = 'none'; + closeDirButton.style.display = ''; + + const schedulesList = document.getElementById('schedules'); + utils.clearChildren(schedulesList); + const options = []; + + let newest = undefined; + let newestTimestamp = undefined; + for await (const entry of dir.values()) { + if (entry.kind === 'file' && entry.name.endsWith('.json')) { + const fileObj = await entry.getFile() + const timestamp = fileObj.lastModified; + if (!newestTimestamp || timestamp > newestTimestamp) { + newest = entry; + newestTimestamp = timestamp; + } + const option = document.createElement('option'); + const title = JSON.parse(await fileObj.text()).title; + option.innerText = title + " (" + entry.name.slice(0, -5) + ") [" + + new Date(timestamp) + "]"; + option.timestamp = timestamp; + option.file = entry; + options.push(option); + } + } + + options.sort((a, b) => b.timestamp - a.timestamp).forEach(opt => + schedulesList.appendChild(opt)); + + await loadFile(newest, false); +} + +if (window.showDirectoryPicker) { + document.getElementById('browserVersionWarning').style.display = 'none'; + + idb_get('dirHandle').then(async dir => { + if (dir) { + if (await dir.queryPermission({mode: 'readwrite'}) === 'granted') { + await loadDir(dir); + } else { + reopenDirButton.style.display = ''; + reopenDirButton.addEventListener('click', async e => { + if (await dir.requestPermission({mode: 'readwrite'}) === 'granted') { + await loadDir(dir); + } + reopenDirButton.style.display = 'none'; + }); + } + } + }); + + closeDirButton.addEventListener('click', e => closeDir()); + + openDirButton.style.display = ''; + openDirButton.addEventListener('click', async e => { + const dir = await window.showDirectoryPicker({mode: 'readwrite'}); + if (dir) { + await idb_set('dirHandle', dir); + await loadDir(dir); + } + }); +} + +function displayFullScheduleAllDays() { + utils.clearChildren(schedulesDiv); + schedule.all_days + .map(day => schedule.fullGridFor(day)) + .forEach(grid => { + const title = document.createElement('h1'); + title.innerText = grid.title; + schedulesDiv.appendChild(title); + schedulesDiv.appendChild(grid.toHtml()); + }) +} + +function refreshDisplay() { + // TODO support different displays. + updateScheduleSettings(); + displayFullScheduleAllDays(); +} + +document.getElementById('changeTitle').addEventListener('click', async e => { + schedule.base_title = document.getElementById('title').value; + saveSchedule(); +}); + +const personInput = document.getElementById('person'); + +document.getElementById('addStudent').addEventListener('click', async e => { + schedule.addStudent(personInput.value); + personInput.value = ''; + saveSchedule(); +}); + +document.getElementById('addStaff').addEventListener('click', async e => { + schedule.addStaff(personInput.value); + personInput.value = ''; + saveSchedule(); +}); + +document.getElementById('addTeacher').addEventListener('click', async e => { + schedule.addTeacher(personInput.value); + personInput.value = ''; + saveSchedule(); +}); + +document.getElementById('delStudent').addEventListener('click', async e => { + schedule.delStudent(document.getElementById('removeStudent').value); + saveSchedule(); +}); + +document.getElementById('delStaff').addEventListener('click', async e => { + schedule.delStaff(document.getElementById('removeStaff').value); + saveSchedule(); +}); diff --git a/www/assignment.js b/www/assignment.js index b9d8a7d..5e82bd4 100644 --- a/www/assignment.js +++ b/www/assignment.js @@ -12,6 +12,19 @@ export default class Assignment { this.people_by_day = obj.people_by_day; this.squishable = obj.squishable ?? false; + if (!this.people_by_day) { + this.people_by_day = {}; + for (const day of this.days) { + this.people_by_day[day] = {staff: [], students: []}; + } + } else if ('all_days' in this.people_by_day) { + const only = this.people_by_day['all_days']; + this.people_by_day = {}; + for (const day of this.days) { + this.people_by_day[day] = {...only}; + } + } + this.all_staff = new Set(Object.values(this.people_by_day) .flatMap(day => day['staff'])); this.all_students = new Set(Object.values(this.people_by_day) @@ -21,8 +34,8 @@ export default class Assignment { asJsonObject() { return { 'location': this.location, - 'start_time': this.start_time, - 'end_time': this.end_time, + 'start_time': this.start_time.asJsonObject(), + 'end_time': this.end_time.asJsonObject(), 'notes': this.notes, 'track': this.track, 'days': this.days, diff --git a/www/idb-keyval.js b/www/idb-keyval.js new file mode 120000 index 0000000..1b8805b --- /dev/null +++ b/www/idb-keyval.js @@ -0,0 +1 @@ +../external/idb-keyval/index.js \ No newline at end of file diff --git a/www/index.html b/www/index.html index d7f207d..b19fb7f 100644 --- a/www/index.html +++ b/www/index.html @@ -7,13 +7,49 @@
- + + +