Basic support for loading schedules, creating and loading backups, changing title, and adding/removing people. Cannot edit events.
parent
72a63f8356
commit
d95ed2cb14
@ -0,0 +1 @@ |
||||
IDB-Keyval from https://www.npmjs.com/package/idb-keyval Apache-2.0 licensed. |
@ -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 }; |
@ -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(); |
||||
}); |
||||
|
@ -0,0 +1 @@ |
||||
../external/idb-keyval/index.js |
Loading…
Reference in new issue