Basic support for loading schedules, creating and loading backups, changing title, and adding/removing people. Cannot edit events.

This commit is contained in:
Daniel Perelman 2022-08-22 05:25:52 -07:00
parent 72a63f8356
commit d95ed2cb14
10 changed files with 596 additions and 12 deletions

1
external/idb-keyval/README.md vendored Normal file
View File

@ -0,0 +1 @@
IDB-Keyval from https://www.npmjs.com/package/idb-keyval Apache-2.0 licensed.

184
external/idb-keyval/index.js vendored Normal file
View File

@ -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 };

View File

@ -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();
});

View File

@ -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,

1
www/idb-keyval.js Symbolic link
View File

@ -0,0 +1 @@
../external/idb-keyval/index.js

View File

@ -7,13 +7,49 @@
<script type="module" src="./app.js"></script>
</head>
<body>
<p class="browserVersionWarning noprint">
This website requires
<a href="https://www.google.com/chrome/">Google Chrome 86+</a>
and does not work in Firefox due to using the
<a href="https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API">File System Access API</a>
to store the schedule data locally on your computer. Apologies for the
inconvenience.
</p>
<div id="header" class="forms noprint">
<p id="browserVersionWarning">
This website requires
<a href="https://www.google.com/chrome/">Google Chrome 86+</a>
and does not work in Firefox due to using the
<a href="https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API">File System Access API</a>
to store the schedule data locally on your computer. Apologies for the
inconvenience.
</p>
<div id="loadButtons">
<button id="openDir" style="display: none;">Choose data directory</button>
<button id="reopenDir" style="display: none;">Reload recent schedule</button>
<button id="closeDir" style="display: none;">Close data directory</button>
</div>
</div>
<div id="scheduleSettings" class="forms noprint">
<div id="selectScheduleDiv">
<label>Schedule: <select id="schedules"></select></label>
</div>
<div id="clone">
<label>New schedule name: <input id="cloneName" pattern="[-_ a-zA-Z0-9]+" disabled></label>
<button id="cloneSch" disabled>Clone to New Schedule</button>
<button id="cloneSchNoAssignments" disabled>Clone to New Schedule without Assignments</button>
</div>
<div id="backupsSettings">
<label>Load backup: <select id="backups"></select></label><br>
<label>Backup name: <input id="backupName" pattern="[-_ a-zA-Z0-9]+"></label>
<button id="createBackup">Create Backup</button>
</div>
<div id="scheduleMetadata">
<label>Title: <input id="title"></label>
<button id="changeTitle">Change Title</button><br>
<label>Name: <input id="person"></label>
<button id="addTeacher">Add Teacher</button>
<button id="addStaff">Add Staff</button>
<button id="addStudent">Add Student</button><br>
<select id="removeStaff" disabled></select>
<button id="delStaff" disabled>Delete Staff Person</button><br>
<select id="removeStudent" disabled></select>
<button id="delStudent" disabled>Delete Student</button><br>
</div>
</div>
<div id="allSchedules">
</div>
</body>
</html>

View File

@ -182,3 +182,7 @@ table.schedule td.event.editing {
.warningHeader label {
cursor: pointer;
}
#scheduleSettings div {
margin: 1em;
}

View File

@ -19,6 +19,25 @@ export default class Schedule {
this.end_time = new utils.Time(obj.end_time);
this.people_info = obj.people_info;
this.lastModified = obj.lastModified ?? new Date();
for (const teacher of this.all_teachers) {
if (!this.all_staff.includes(teacher)) {
this.all_staff.push(teacher);
}
}
this.all_students.sort();
this.all_staff.sort();
this.all_teachers.sort();
this.recomputeNotAssigned();
}
recomputeNotAssigned() {
this.assignedStaff = new Set(this.assignments.flatMap(a => [...a.all_staff]));
this.assignedStudents = new Set(this.assignments.flatMap(a => [...a.all_students]));
this.notAssignedStaff = utils.setDifference(this.all_staff, this.assignedStaff);
this.notAssignedStudents = utils.setDifference(this.all_students, this.assignedStudents);
}
asJsonObject() {
@ -60,4 +79,45 @@ export default class Schedule {
.map(a => a.asEvent(day, this.granularity, false)),
})
}
addStudent(name) {
if (this.all_students.includes(name)) return false;
this.all_students.push(name);
this.all_students.sort();
this.notAssignedStudents.add(name);
}
addStaff(name) {
if (this.all_staff.includes(name)) return false;
this.all_staff.push(name);
this.all_staff.sort();
this.notAssignedStaff.add(name);
}
addTeacher(name) {
if (!this.addStaff(name)) return false;
this.all_teachers.push(name);
this.all_teachers.sort();
this.notAssignedStaff.add(name);
}
delStaff(name) {
if (!this.notAssignedStaff.has(name)) {
throw "Tried to remove staff " + name + " who has assignments.";
}
this.notAssignedStaff.delete(name);
this.all_staff.splice(this.all_staff.indexOf(name), 1);
const teacherIdx = this.all_teachers.indexOf(name);
if (teacherIdx > -1) this.all_teachers.splice(teacherIdx, 1);
}
delStudent(name) {
if (!this.notAssignedStudents.has(name)) {
throw "Tried to remove student " + name + " who has assignments.";
}
this.notAssignedStudents.delete(name);
this.all_students.splice(this.all_students.indexOf(name), 1);
}
}

View File

@ -89,6 +89,7 @@ export default class ScheduleGrid {
return eventTracks.map(t => {
const eventClone = event.clone();
eventClone.track = t;
return eventClone;
});
}

View File

@ -106,3 +106,16 @@ export function DayString(day) {
'U': 'Sunday',
}[day];
}
export function clearChildren(el) {
while (el.firstChild) el.removeChild(el.lastChild);
}
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set
export function setDifference(setA, setB) {
const _difference = new Set(setA);
for (const elem of setB) {
_difference.delete(elem);
}
return _difference;
}