/**
* @file DTPS Core functions and module loader
* @author jottocraft
* @version v3.9.2
*
* @copyright Copyright (c) 2018-2023 jottocraft
* @license MIT
*/
//Make sure DTPS isn't already loading
if (typeof dtps !== "undefined") throw "Error: DTPS is already loading";
/**
* Global DTPS object
* All global DTPS functions and variables are stored in this object
*
* @global
* @namespace dtps
* @property {number} ver Comparable version number
* @property {string} env DTPS enviornment ("prod" or "dev")
* @property {string} readableVer Formatted semantic version number
* @property {Class[]} classes Array of classes the current user is in
* @property {string} baseURL The base URL that DTPS is being loaded from
* @property {boolean} unstable This is true if loading an unstable version of DTPS
* @property {boolean} gradebookExpanded True if gradebook details (Show more...) is open. Used in the generic gradebook and may be used in custom gradebook implementations.
* @property {boolean} reloadPending True if the user has made changes that require a reload
* @property {string|undefined} popup Popup to show when loaded. Either undefined (no popup), "firstrun", or "changelog".
* @property {User} user Current user, fetched with dtps.init
* @property {number|string} selectedClass The selected class number, or "dash" if the dashboard is selected. Set when the first screen is loaded in dtps.init.
* @property {string} selectedContent The selected class content/tab. Set when the first class content is loaded. Defaults to "stream".
* @property {number|undefined} bgTimeout Background transition timeout when switching between classes
* @property {Array<Assignment|Announcement>} updates Array of up to 10 recently graded assignments or announcements.
* @property {DashboardItem[]} dashboardItems Array of items that can be shown on the dashboard
* @property {DashboardItem[]} leftDashboard Items on the left side of the dashboard based on dtps.dashboardItems and user prefrences. Set in dtps.loadDashboardPrefs.
* @property {DashboardItem[]} rightDashboard Items on the right side of the dashboard based on dtps.dashboardItems and user prefrences. Set in dtps.loadDashboardPrefs.
* @property {object} remoteConfig Configuration variables that can be remotely changed
* @property {boolean} searchScrollListener True if the search scroll listener has been added
* @property {string} cblSpec A URL to the CBL specification document used by dangerous Power+ CBL features
*/
var dtps = {
ver: 3_09_2,
readableVer: "v3.9.2",
env: new URL(window.dtpsBaseURL || "https://powerplus.app").hostname == "localhost" ? "dev" : window.jottocraftSatEnv || "prod",
classes: [],
baseURL: window.dtpsBaseURL || "https://powerplus.app",
unstable: window.dtpsBaseURL !== "https://powerplus.app",
gradebookExpanded: false,
reloadPending: false,
updates: [],
cblSpec: "https://docs.google.com/document/d/1AO5OcQozr1HAt_gT-mfmv8A2ibUP2l3PHPYWoL3xivo/edit",
dashboardItems: [
{
name: "Calendar",
id: "dtps.calendar",
icon: "event",
size: 100,
defaultSide: "left"
}, {
name: "Updates",
id: "dtps.updates",
icon: "calendar_view_day",
size: 170,
defaultSide: "left"
}, {
name: "Due Today",
id: "dtps.dueToday",
icon: "check_circle_outline",
size: 20,
defaultSide: "right"
}, {
name: "Upcoming Assignments",
id: "dtps.upcoming",
icon: "view_stream",
size: 250,
defaultSide: "right"
}
],
remoteConfig: {
allowWhatIfGrades: true,
canvasRequestSpacing: 25,
debugClassID: "1455",
gradeCalculationEnabled: true,
loadingAlert: false,
remoteUpdate: {
title: null,
html: null,
host: null,
active: false
}
}
};
//Load jQuery ASAP
jQuery.getScript("https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js");
/**
* Debugging shortcut for getting the selected class. This should only be used in the web inspector and not in actual code.
*
* @return {object|undefined} The selected class
*/
dtps.class = function () {
return dtps.classes[dtps.selectedClass];
};
//Detect debug info shortcut
document.onkeydown = function (event) {
if (event.ctrlKey && event.altKey && (event.code == "KeyD")) {
var replacer = function (key, value) {
//Truncate long properties
if (["assignments", "people", "modules", "discussions", "pages", "outcomes"].includes(key)) {
if (value instanceof Array) return "[truncated] (array, length " + value.length + ")";
if (typeof value == "object") return "[truncated] (object, keys " + Object.keys(value).length + ")";
return value;
}
//Remove sensitive properties/PII
if (["letter", "number75", "grade", "lowestScore"].includes(key)) return "[redacted] (" + (typeof value) + ")";
return value;
};
$(".card.details").html(/*html*/`
<i onclick="fluid.cards.close('.card.details'); $('.card.details').html('');" class="fluid-icon close">close</i>
<h4 style="font-weight: bold;">Debug</h4>
<div style="margin: 40px 0px;">
<h5>User</h5>
<pre><code class="prettyprint">${JSON.stringify(dtps.user, null, '\t')}</code></pre>
</div>
${dtps.classes[dtps.selectedClass] ? /*html*/`
<div style="margin: 40px 0px;">
<h5>Selected class</h5>
<pre><code class="prettyprint">${JSON.stringify(dtps.classes[dtps.selectedClass], replacer, '\t')}</code></pre>
</div>
` : ``
}
`);
if (!window.PR) {
$.getScript("https://cdn.jsdelivr.net/gh/google/code-prettify@master/loader/run_prettify.js?lang=json");
} else {
PR.prettyPrint();
}
//Close other active cards and open the assignment details card
fluid.cards.close(".card.focus");
fluid.cards(".card.details");
}
}
/**
* Fetches and displays the DTPS changelog modal
*
* @param {number} fromVersion The previous version of DTPS installed. Will show popup changelogs since that version, if there aren't any, it will silently skip.
* @param {boolean} draft True to show draft changelogs
*/
dtps.changelog = function (fromVersion, draft = false) {
let changelogFetchURL;
if (fromVersion) {
//Check for popup w/ all previous important updates query
changelogFetchURL = `https://dev.jottocraft.com/api/issues?query=${encodeURIComponent(`project: RRS product: dtps rollout: released version code: ${fromVersion + 1} .. ${dtps.ver} popup: popup sort by: {version code} desc`)}&fields=summary,description,idReadable,customFields(projectCustomField(field(name)),value(name))`;
} else {
//Latest query
changelogFetchURL = `https://dev.jottocraft.com/api/issues?query=${encodeURIComponent(`project: RRS product: dtps rollout: ${draft ? "draft" : "released"} version code: * .. ${dtps.ver} sort by: {version code} desc`)}&fields=summary,description,idReadable,customFields(projectCustomField(field(name)),value(name))&$top=1`;
}
jQuery.getScript("https://cdnjs.cloudflare.com/ajax/libs/showdown/1.8.6/showdown.min.js", function () {
markdown = new showdown.Converter();
//Fetch changelog for the current release
jQuery.getJSON(changelogFetchURL, function (data) {
window.localStorage.setItem('dtps', dtps.ver);
let releasesHTML = [];
data.forEach(release => {
const releaseDate = release.customFields?.find(f => f.projectCustomField?.field?.name === "Release date")?.value;
const releaseDateFormatted = new Date(releaseDate).toLocaleDateString();
//Convert changelog to HTML and render in the changelog card
var changelogHTML = markdown.makeHtml(release.description);
releasesHTML.push(`
<div style="display: flex; flex-direction: row; align-items: center;">
<i style="margin-right: 6px;" class="fluid-icon">browser_updated</i>
<h4 style="margin: 6px 0px;">${release.summary}</h4>
</div>
<div style="display: flex; flex-direction: row; align-items: center; color: var(--lightText);">
<i style="margin-right: 4px; font-size: 18px;" class="fluid-icon">event</i>
<p style="margin: 6px 0px;">${releaseDateFormatted}</p>
</div>
<br />
<div id="changelogStyles">
${changelogHTML}
</div>
`);
});
if (!releasesHTML.length) {
if (fromVersion) {
//No data for popups, silently skip
return;
}
releasesHTML.push(`<p>Couldn't find any changelog data</p>`);
}
jQuery(".card.changelog").html(`
<i onclick="fluid.cards.close('.card.changelog')" class="fluid-icon close">close</i>
${releasesHTML.join(`<div style="margin: 40px 0px !important;" class="divider"></div>`)}
<br />
<button onclick="window.open('https://dev.jottocraft.com/issues?q=project:%20RRS%20product:%20dtps%20rollout:%20released%20sort%20by:%20%7Bversion%20code%7D%20desc')" class="btn small outline"><i class="fluid-icon">update</i> View previous changelogs</button>
`);
//Show changelog
fluid.cards.close(".card.focus");
fluid.cards(".card.changelog");
});
});
};
/**
* Logs debugging messages
*
* @param {...*} msg The debugging messages to log
*/
dtps.log = function () {
console.log("[DTPS]", ...arguments);
}
/**
* Shows an error message alert and logs to console
*
* @param {string} msg The error to display
* @param {string} [devNotes] Technical error details displayed in a smaller font
* @param {Error} [err] An error object to log to the console. If this is null, DTPS will assume the error has been handled elsewhere.
*/
dtps.error = function (msg, devNotes, err) {
if (err !== null) {
var formattedDevNotes = "";
if (devNotes) {
formattedDevNotes = '<div style="font-size: 12px; color: var(--secText, gray); margin-top: 10px;">' + devNotes + '</div>';
}
console.error("[DTPS !!ERROR!!]", devNotes + ": ", err);
fluid.alert("Error", msg + formattedDevNotes, "error");
}
}
/**
* Renders "Welcome to Project DTPS" screen on the first run
*/
dtps.firstrun = function () {
//Set latest changelog version to current version
window.localStorage.setItem('dtps', dtps.ver);
//Welcome to DTPS screen HTML
jQuery(".card.changelog").html(/*html*/`
<h3 style="margin-bottom: 0px;">Welcome to Power+</h3>
<h5 style="color: var(--secText); font-weight: bold; font-size: 22px;">${dtps.readableVer}</h5>
<div class="welcomeSection">
<i class="fluid-icon">dashboard</i>
<h5>${dtpsLMS.gradebook ? "Manage your coursework and grades" : "Manage your coursework"}</h5>
<p>Power+ organizes all of your coursework so you can easily see what you need to do next. The dashboard shows upcoming assignments, recent grades, and announcements.
${dtpsLMS.dtech ? `<p style="color: var(--text);"><b>As of June 2022, d.tech CBL features are no longer actively updated. If you'd like to use these potentially obsolete features, you must opt-in at Settings (top-right) -> CBL.</b></p>` : ``}
</div>
${dtpsLMS.isDemoLMS ? /*html*/`
<div class="welcomeSection">
<i class="fluid-icon">priority_high</i>
<h5>This is a demo</h5>
<p>Assignment information, grades, and other content displayed is not real and is for demonstration purposes only. This demo does not retrieve or collect any data.</p>
</div>
` : /*html*/`
<div class="welcomeSection">
<i class="fluid-icon">security</i>
<h5>Privacy</h5>
<p>
Power+ records your device and network type, Canvas institution, and region when you load Power+.
Analytics events are associated with a unique ID generated with a one-way-hash from your Canvas ID and institution.
Data is collected directly by Power+ and is never shared with or sold to any third parties.
Power+ does <b>not</b> and will <b>never</b> collect any personal information, such as names, classes, or grades.
</p>
</div>
<div class="welcomeSection">
<i class="fluid-icon">priority_high</i>
<h5>Power+ is not official</h5>
<p>Assignment information, grades, and other content displayed in Power+ are not official. Power+ is neither created nor endorsed by ${dtpsLMS.legalName}.
Power+ may have bugs that could cause it to display inaccurate information. Use Power+ at your own risk.</p>
</div>
`}
<br />
<button onclick="window.localStorage.setItem('dtpsInstalled', 'true'); fluid.cards.close('.card.changelog');" class="btn">
<i class="fluid-icon">arrow_forward</i> Continue
</button>
`);
//Show Welcome to DTPS card
fluid.cards.close(".card.focus");
fluid.cards(".card.changelog", "stayOpen");
};
/**
* Renders the DTPS loading screen
*/
dtps.renderLoadingScreen = function () {
if (!window.dtpsPreLoader || dtps.user) {
//Only show the loader if the extension hasn't already shown it
jQuery("body").append(/*html*/`
<div id="dtpsNativeOverlay" style="background-color: #151515; position: fixed; top: 0px; left: 0px; width: 100%; height: 100vh; z-index: 99;text-align: center;z-index: 999;transition: opacity 0.2s;">
<img style="height: 100px; margin-top: 132px;" src="https://i.imgur.com/7dDUVh2.png" />
<br />
<div class="progress"><div class="indeterminate"></div></div>
<style>body {background-color: #151515; overflow: hidden;}*,:after,:before{box-sizing:border-box}.progress{position:relative;width:600px;height:5px;overflow:hidden;border-radius:12px;background:#262626;backdrop-filter:opacity(.4);display:inline-block;margin-top:75px}.progress .indeterminate{position:absolute;background:#e3ba4b;height:5px;animation:indeterminate 1.4s infinite;animation-timing-function:linear}@keyframes indeterminate{0%{width:5%;left:-15%}to{width:100%;left:110%}}</style>
</div>
`);
}
}
/**
* Load all external JavaScript libraries
*
* @param {function} cb Callback function
*/
dtps.JS = function () {
return new Promise((resolve, reject) => {
//Moment & Fullcalendar are used for the calendar on the dashboard
jQuery.getScript("https://cdn.jsdelivr.net/npm/fullcalendar@5.3.2/main.min.js", function () {
jQuery.getScript("https://cdn.jsdelivr.net/npm/fullcalendar@5.3.2/locales-all.min.js");
});
//Lunr is used for search
jQuery.getScript('https://unpkg.com/lunr@2.3.9/lunr.min.js');
//jQuery UI for dashboard settings page
jQuery.getScript('https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js');
//Tinycolor used for better dark mode support
jQuery.getScript("https://cdn.jottocraft.com/tinycolor.js");
//dtao/nearest-color is used for finding the nearest class color
jQuery.getScript("https://cdn.jottocraft.com/nearest-color.dtao.js", () => {
//Fluid UI for core UI elements
jQuery.getScript(dtps.baseURL + "/fluid/fluid.js", resolve);
});
});
}
/**
* Load all DTPS CSS files
*/
dtps.CSS = function () {
jQuery("<link/>", {
rel: "stylesheet",
type: "text/css",
href: dtps.baseURL + "/fluid/fluid.css",
class: "dtpsHeadItem"
}).appendTo("head");
jQuery("<link/>", {
rel: "stylesheet",
type: "text/css",
href: dtps.baseURL + "/dtps.css",
class: "dtpsHeadItem"
}).appendTo("head");
jQuery("<link/>", {
rel: "stylesheet",
type: "text/css",
href: "https://fonts.googleapis.com/css?family=Material+Icons+Round",
class: "dtpsHeadItem"
}).appendTo("head");
jQuery("<link/>", {
rel: "stylesheet",
type: "text/css",
href: "https://cdn.jsdelivr.net/npm/fullcalendar@5.3.2/main.min.css",
class: "dtpsHeadItem"
}).appendTo("head");
jQuery("<link/>", {
rel: "stylesheet",
type: "text/css",
href: "https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css",
class: "dtpsHeadItem"
}).appendTo("head");
}
/**
* Gets the amount of minutes elapsed in the day
*
* @param {date} d The day
* @returns The amount of minutes or null if the date is invalid
*/
dtps.dayTimeMinutes = function (d) {
if (!d) return null;
d = new Date(d);
if (isNaN(d.getTime())) return null;
return (d.getHours() * 60) + d.getMinutes();
}
/**
* Starts DTPS (entrypoint function)
*/
dtps.init = function () {
//Initial log
dtps.log("Starting DTPS " + dtps.readableVer + "...");
//Disable dev version
if (window.localStorage.getItem('dtpsLoaderPref') == "dev") {
window.localStorage.removeItem('dtpsLoaderPref');
}
//Check for LMS config
if (!window.dtpsLMS) {
throw "Error: No DTPS LMS configuration found";
}
//Determine if changelog should be shown
if (Number(window.localStorage.dtps) < dtps.ver) {
dtps.log("New release");
dtps.popup = "changelog";
}
//Determine if this is the first time loading DTPS
if (window.localStorage.dtpsInstalled !== "true") {
dtps.popup = "firstrun";
}
//Render loading screen
dtps.renderLoadingScreen();
//Get URL parameters
var urlParams = new URLSearchParams(window.location.search);
if (Array.from(urlParams).length) window.history.replaceState(null, null, window.location.pathname);
//Fluid UI settings
window.fluidConfig = {
config: {
autoLoad: false,
allowThemeMenu: false //This is false because Power+ uses its own theme settings UI
}
};
//Set env discovery
window.localStorage.setItem("jottocraftEnv", true);
//Default selected content
dtps.selectedContent = "stream";
//Begin loading CSS
dtps.CSS();
//Begin loading static DTPS HTML
dtps.render();
//Fetch remote config and JavaScript modules
Promise.all([
new Promise((r) => {
jQuery.getJSON('https://jottocraft-dtps-default-rtdb.firebaseio.com/config.json', (remoteConfig) => {
if (remoteConfig) {
Object.keys(remoteConfig).forEach(k => {
var val = remoteConfig[k];
dtps.remoteConfig[k] = val;
});
}
r();
}).fail(() => r());
}),
dtps.JS()
]).then(() => {
//dev env remote config overrides
if (dtps.env == "dev") {
Object.keys(dtps.remoteConfig).forEach(k => {
if (window.localStorage.getItem("dtpsRemoteConfig-" + k)) {
val = window.localStorage.getItem("dtpsRemoteConfig-" + k);
if (val == "true") val = true;
if (val == "false") val = false;
dtps.remoteConfig[k] = val;
}
});
}
//Fetch user and class data
return dtpsLMS.fetchUser();
}).then(data => {
if (dtps.remoteConfig.loadingAlert) window.alert(dtps.remoteConfig.loadingAlert);
//Prevent resetting the user if the user is already set (for parent accounts)
dtps.user = data;
//If this is a parent account, show the parent UI
if (dtps.user.children && dtps.user.children.length) {
dtps.user.parent = true;
//Fetch classes for all students
return new Promise((resolve, reject) => {
var allClasses = [];
var promises = [];
dtps.user.children.forEach(child => {
promises.push(new Promise(function (resolve, reject) {
dtpsLMS.fetchClasses(child.id).then((data) => {
//Prepend child ID to class ID
data.forEach(course => {
course.id = child.id + "-" + course.id;
course.group = child.name;
});
allClasses = allClasses.concat(data);
resolve();
}).catch(reject);
}));
});
Promise.all(promises).then(() => {
resolve(allClasses);
});
});
} else {
return dtpsLMS.fetchClasses(dtps.user.id);
}
}).then((rawClasses) => {
return new Promise((resolve, reject) => {
if (dtpsLMS.institutionSpecific && dtpsLMS.updateClasses) {
//Using an institution-specific script, make any nessasary changes and return updated classes
dtpsLMS.updateClasses(rawClasses).then((updatedClasses) => {
resolve(updatedClasses);
}).catch((e) => {
reject(e);
});
} else {
//No institution-specific script, return classes as-is
resolve(rawClasses);
}
});
}).then(classData => {
//Store classData to dtps.classes
dtps.classes = classData;
//Add course num props
dtps.classes.forEach((course, index) => course.num = index);
//Define DTPS class color pallete
var nearestCourseColor = nearestColor.from({
//Primary color shades
red: "#cc4747",
brown: "#9a4429",
orange: "#ce734e",
pumpkin: "#c58535",
yellow: "#b58f38",
yellowGreen: "#7c9630",
green: "#439a47",
oceanGreen: "#339a70",
keppel: "#40b5c3",
skyBlue: "#40a4d6",
blue: "#2d72a0",
purple: "#985cab",
magenta: "#bd3e69",
pink: "#ec7ca5",
gray: "#888888",
//Dark color shades
gold: "#94652a",
darkRed: "#943f3f",
darkBlue: "#485182",
darkPurple: "#6f4882",
darkGreen: "#3d7358",
//Light color shades
lightOrange: "#ea7a44",
lightRed: "#e07f7f",
lightBlue: "#76c1e6",
lightPurple: "#978fd6",
lightGreen: "#7ebb95",
lightBrown: "#928871"
});
//Loop over each class to update the color and check for grade calculation
dtps.classes.forEach(course => {
course.color = course.color ? nearestCourseColor(course.color).value : "gray";
const classGradeCalcAllowed = dtpsLMS.gradeCalculationAllowlist ? dtpsLMS.gradeCalculationAllowlist.includes(course.id) : true;
if (dtpsLMS.calculateGrade && classGradeCalcAllowed && dtps.remoteConfig.gradeCalculationEnabled) {
//This LMS/Institution supports grade calculation, show loading indicator for grade
//Grade will be calculated once assignments are fetched
course.letter = "..."; //Setting this to ... shows the loading indicator
course.grade = undefined;
}
});
//Fluid UI screens
fluid.defaultScreen = "dashboard";
fluid.screens.dashboard = dtps.baseURL + "/scripts/assignments.js";
fluid.screens.stream = dtps.baseURL + "/scripts/assignments.js";
fluid.screens.moduleStream = dtps.baseURL + "/scripts/assignments.js";
fluid.screens.people = dtps.baseURL + "/scripts/people.js";
fluid.screens.search = dtps.baseURL + "/scripts/search.js";
fluid.screens.pages = dtps.baseURL + "/scripts/pages-discussions.js";
fluid.screens.discussions = dtps.baseURL + "/scripts/pages-discussions.js";
fluid.screens.gradebook = dtps.baseURL + "/scripts/assignments.js";
//Begin fetching class assignments
var fetchedAnnouncements = [];
dtps.classes.forEach((course, courseIndex) => {
dtpsLMS.fetchAssignments(course.userID, course.lmsID).then((rawAssignments) => {
return new Promise((resolve, reject) => {
if (dtpsLMS.institutionSpecific && dtpsLMS.updateAssignments) {
//Using an institution-specific script, make any nessasary changes and return updated assignments
dtpsLMS.updateAssignments(rawAssignments, course).then(updatedAssignments => {
resolve(updatedAssignments);
}).catch(reject);
} else {
//No institution-specific script, return assignments as-is
resolve(rawAssignments);
}
});
}).then(assignments => {
//Map due date array
const dueDates = assignments.map(a => dtps.dayTimeMinutes(a.dueAt)).filter(d => d !== null).sort((a, b) => a - b);
if (dueDates && dueDates.length >= 10) {
//Get due date IQR
var q1 = dueDates[Math.floor((dueDates.length / 5))];
var q3 = dueDates[Math.ceil((dueDates.length * (4 / 5)))];
var iqr = q3 - q1;
//Find min and max within reasonable range
course.usualDueRange = [(q1 - (iqr * 1.5)) - 120, (q3 + (iqr * 1.5)) + 120];
}
//Store assignments in the class
course.assignments = assignments;
//Add class props to assignments and add recent assignments to updates array
course.assignments.forEach(assignment => {
assignment.class = courseIndex;
if (assignment.gradedAt && assignment.grade) {
//Add class number and type to object
dtps.updates.push({
class: course.num,
type: "assignment",
...assignment
})
}
});
//Sort updates array from newest -> oldest
dtps.updates.sort(function (a, b) {
//Sort by postedAt (announcements) or gradedAt (assignments)
return new Date(b.gradedAt || b.postedAt).getTime() - new Date(a.gradedAt || a.postedAt).getTime()
});
//Keep only the 15 most recent updates
if (dtps.updates.length > 15) dtps.updates.length = 15;
//Calculate class grade if supported
const classGradeCalcAllowed = dtpsLMS.gradeCalculationAllowlist ? dtpsLMS.gradeCalculationAllowlist.includes(course.id) : true;
if (dtpsLMS.calculateGrade && classGradeCalcAllowed && dtps.remoteConfig.gradeCalculationEnabled) {
let gradeCalcResults = dtpsLMS.calculateGrade(course, assignments);
if (gradeCalcResults) {
//This class has a grade
//Set course letter and grade to grade calc results
course.letter = gradeCalcResults.letter;
course.grade = gradeCalcResults.grade;
//Save raw results object to the course so it can be accessed by the gradebook
course.gradeCalculation = gradeCalcResults;
} else {
//No grade for this class
course.letter = null;
course.grade = null;
}
//Force re-render sidebar to show grades only if the user isn't on pages or discussions
if ((dtps.selectedContent !== "pages") && (dtps.selectedContent !== "discuss")) {
dtps.showClasses(true);
}
}
//Grade history and gradebook
if ((course.letter || course.grade) && (course.letter !== "...")) {
//Enable gradebook for this class
course.hasGradebook = true;
//Save grade history
dtps.logGrades(courseIndex);
//If the class is selected, call dtps.presentClass again to show the grades tab
if (courseIndex == dtps.selectedClass) {
dtps.presentClass(courseIndex);
//If the gradebook is selected, reload the gradebook
if (dtps.selectedContent == "grades") {
fluid.screen('gradebook', dtps.classes[courseIndex].id);
}
}
} else {
//This class doesn't have a gradebook, exit gradebook if selected
if ((courseIndex == dtps.selectedClass) && (dtps.selectedContent == "grades")) {
fluid.screen('stream', dtps.classes[courseIndex].id);
}
}
//Render grades tab in settings
dtps.renderGradesInSettings();
//Re-render screen if the dashboard or stream is selected
if ((dtps.selectedClass == "dash") || ((dtps.selectedContent == "stream") && (dtps.selectedClass == course.num))) {
fluid.screen();
}
});
if (!fetchedAnnouncements.includes(course.lmsID)) {
//Add lmsID to list of fetched announcements to prevent duplicates
fetchedAnnouncements.push(course.lmsID);
dtpsLMS.fetchAnnouncements(course.lmsID).then(announcements => {
//Add announcements to updates array
announcements.forEach(announcement => {
//Add class number and type to object
dtps.updates.push({
class: course.num,
type: "announcement",
...announcement
});
});
//Sort updates array from newest -> oldest
dtps.updates.sort(function (a, b) {
//Sort by postedAt (announcements) or gradedAt (assignments)
return new Date(b.gradedAt || b.postedAt).getTime() - new Date(a.gradedAt || a.postedAt).getTime()
});
//Keep only the 15 most recent updates
if (dtps.updates.length > 15) dtps.updates.length = 15;
if (dtps.selectedClass == "dash") {
fluid.screen();
}
});
}
});
//Render remaining HTML
dtps.renderLite();
//Render initial screen
fluid.screen();
//Load popup if needed
if (dtps.popup == "firstrun") {
dtps.firstrun();
} else if (dtps.popup == "changelog") {
//Changelog will only show if the release notes are on dev.jottocraft.com
dtps.changelog(Number(window.localStorage.dtps));
}
//Render inbox
if (dtpsLMS.fetchUnreadMessageCount) {
dtpsLMS.fetchUnreadMessageCount().then(count => {
jQuery("#dtpsUnreadCount span").text(count);
if (Number(count) > 0) {
jQuery("#dtpsUnreadCount").addClass("active");
}
jQuery("#dtpsUnreadCount").show();
});
}
}).catch(function (err) {
//Web request error
console.error("[DTPS] Error fetching user and classes at dtps.init", err);
//Check for login redirect
if ((err.action == "login") && err.redirectURL) {
//Redirect to login page
window.location.href = err.redirectURL;
} else {
dtps.error("Failed to get user and/or course data", "Exception in promise @ dtps.init");
}
});
//Load dashboard prefrences
dtps.loadDashboardPrefs();
}
/**
* Formats a date to a readable date string
*
* @param {Date} date The date to format
* @return {string} Formatted date and time string
*/
dtps.formatDate = function (date) {
if (date) {
return new Date(date).toLocaleString("en", { weekday: 'short', month: 'short', day: 'numeric', hour: "numeric", minute: "numeric" });
} else {
return "";
}
}
/**
* Adjusts the height of an iFrame to match its content
*
* @param {string} iframeID The ID of the iFrame element to adjust
*/
dtps.iframeLoad = function (iframeID) {
var iFrame = document.getElementById(iframeID);
if (iFrame) {
iFrame.height = "";
iFrame.height = iFrame.contentWindow.document.body.scrollHeight + "px";
}
}
/**
* Clears all DTPS data
*/
dtps.clearData = function () {
if (window.confirm("Clearing Power+ data will clear all local user data stored by Power+. This should be done if it is a new semester / school year or if you are having issues with Power+. Are you sure you want to clear all your Power+ data?")) {
window.localStorage.clear()
window.alert("Power+ data cleared")
}
}
/**
* Renders the class list in the sidebar
*
* @param {boolean} [override] True if the sidebar should be forcefully re-rendered
*/
dtps.showClasses = function (override) {
//Array of class HTML for the sidebar
dtps.classlist = [];
var previousClassGroup = null;
for (var i = 0; i < dtps.classes.length; i++) {
//Determine letter grade HTML
var letterGradeHTML = "";
if (dtps.classes[i].letter) letterGradeHTML = dtps.classes[i].letter;
if (dtps.classes[i].letter == null) letterGradeHTML = "--";
if (dtps.classes[i].letter == "...") letterGradeHTML = `<div class="shimmer" style="vertical-align:middle;display: inline-block;width: 22px;height: 22px;border-radius: 8px;"></div>`; //Show loading indicator for ...
if (dtps.classes[i].letter == "ERROR") letterGradeHTML = `<i class="fluid-icon">error</i>`;
if (dtps.classes[i].group && (dtps.classes[i].group !== previousClassGroup)) {
if (previousClassGroup) dtps.classlist.push(`</div></div>`);
dtps.classlist.push(/*html*/`
<div id="dtpsClassListGroup-${dtps.classes[i].group}" class="group open">
<div class="name item">
<i class="fluid-icon"></i> <span class="label">${dtps.classes[i].group}</span>
</div>
<div class="items">
`);
} else if (!dtps.classes[i].group && previousClassGroup) {
dtps.classlist.push(`</div></div>`);
}
//Add class HTML to array
dtps.classlist.push(/*html*/`
<div
onclick="${'dtps.selectedClass = ' + i}"
class="${'class item ' + i + ' ' + (dtps.selectedClass == i ? " active" : "")}"
style="${'--classColor: ' + dtps.classes[i].color}"
>
${dtps.classes[i].grade && dtps.classes[i].letter ? /*html*/`
<i class="fluid-icon grade">
<span class="letter">${letterGradeHTML}</span>
<span class="percentage ${Math.round(dtps.classes[i].grade) >= 100 ? "large" : ""}">${Math.round(dtps.classes[i].grade)}<span class="percentSymbol">%</span></span>
</i>
` : dtps.classes[i].grade ? /*html*/`
<i class="fluid-icon grade percentage ${Math.round(dtps.classes[i].grade) >= 100 ? "large" : ""}">
${Math.round(dtps.classes[i].grade)}<span class="percentSymbol">%</span>
</i>
` : /*html*/`
<i class="fluid-icon grade letter">
${letterGradeHTML}
</i>
`}
<span class="label">${dtps.classes[i].subject}</span>
</div>
`);
previousClassGroup = dtps.classes[i].group;
}
if (previousClassGroup) dtps.classlist.push(`</div></div>`);
//Only render HTML if the sidebar doesn't already have the classes rendered, or if override is true
if (!jQuery(".sidebar .class.dash")[0] || override) {
jQuery(".sidebar").html(/*html*/`
<div class="title">
<img src="${dtps.baseURL + "/icon.svg"}" />
<h4>Power+</h4>
</div>
<div class="items">
<div onclick="dtps.selectedClass = 'dash';" class="class item main dash ${dtps.selectedClass == "dash" ? "active" : ""}">
<i class="fluid-icon">dashboard</i>
<span class="label">Dashboard</span>
</div>
<div class="divider"></div>
${dtps.classlist.join("")}
</div>
<div class="collapse">
<i class="fluid-icon"></i>
</div>
`);
fluid.init();
//Change view to stream if coming from pages or discussions, since those tabs change the sidebar
if ((dtps.selectedContent == "pages") || (dtps.selectedContent == "discuss")) {
//Show stream
if (dtps.classes[dtps.selectedClass]) fluid.screen('stream', dtps.classes[dtps.selectedClass].id);
}
//Class onclick listener
$(".class").click(function (event) {
//Load class content based on what's selected
if (((dtps.selectedContent == "stream") || (dtps.selectedContent == "moduleStream")) && dtps.classes[dtps.selectedClass]) {
if (dtps.classes[dtps.selectedClass].modules && (window.localStorage.getItem("courseworkPref-" + dtps.classes[dtps.selectedClass].id) == "moduleStream")) {
fluid.screen("moduleStream", dtps.classes[dtps.selectedClass].id);
} else {
fluid.screen("stream", dtps.classes[dtps.selectedClass].id);
}
}
if ((dtps.selectedContent == "people") && dtps.classes[dtps.selectedClass]) {
fluid.screen("people", dtps.classes[dtps.selectedClass].id);
}
if ((dtps.selectedContent == "grades") && dtps.classes[dtps.selectedClass]) {
if (dtps.classes[dtps.selectedClass].hasGradebook) {
fluid.screen("gradebook", dtps.classes[dtps.selectedClass].id);
} else {
fluid.screen("stream", dtps.classes[dtps.selectedClass].id);
}
}
if (dtps.selectedClass == "dash") {
fluid.screen("dashboard");
}
});
}
}
/**
* Renders the class header (color, name, tabs, etc.) and sets the class as the selected class
*
* @param {number|string} classNum The class number to load or "dash" if loading the dashboard
*/
dtps.presentClass = function (classNum) {
//Set classNum as selected class
dtps.selectedClass = classNum;
//Update document title with class name
if (dtps.classes[classNum]) {
document.title = dtps.classes[classNum].subject + " | Power+";
} else {
document.title = "Power+";
}
//Set the class image
if ((fluid.get("pref-hideClassImages") != "true") && dtps.classes[classNum] && dtps.classes[classNum].image) {
$(".headerArea").addClass("classImage");
$(".headerArea img").attr("src", dtps.classes[classNum].image);
$(".headerArea img").show();
} else {
$(".headerArea").removeClass("classImage");
$(".headerArea img").hide();
}
//Remove active class from other classes in the sidebar, add the active class to the selected class
$(".sidebar .class").removeClass("active");
$(".class." + classNum).addClass("active");
//Update title to show class name
//If the dashboard is selected, this is just "Dashboard". Otherwise, this is Class.subject
$("#headText span").text(classNum == "dash" ? "Dashboard" : classNum == "search" ? "Search Results" : dtps.classes[classNum] && dtps.classes[classNum].subject);
var icon = classNum == "dash" ? "dashboard" : classNum == "search" ? "search" : dtps.classes[classNum] && dtps.classes[classNum].icon
if (icon) {
$("#headText i").text(icon);
$("#headText i").show();
} else {
$("#headText i").hide();
}
$("#headText").css("color", (classNum == "dash") || (classNum == "search") ? "var(--text)" : dtps.classes[classNum] && dtps.classes[classNum].color);
//If the class doesn't exist, hide the tabs
//Otherwise, show the tabs
if (!dtps.classes[classNum]) {
$("#dtpsTabBar").hide();
$("#classInfo p").hide();
} else {
$("#dtpsTabBar").show();
}
//Hide dashboard start date
if (classNum !== "search") $(".headerArea .contentLabel").hide();
//Clear search box if not on the search tab
if ((classNum !== "search") && !$("#dtpsMainSearchBox").is(":focus")) {
$("#dtpsMainSearchBox").val("");
}
//Set search box content
dtps.setSearchBox();
if (dtps.classes[classNum]) {
//Show pages tab if the class supports it, otherwise, hide it
if (dtps.classes[classNum].pages) {
$(".btns .btn.pages").show();
} else {
$(".btns .btn.pages").hide();
}
//Show people tab if the LMS supports it, otherwise, hide it
if (dtps.classes[classNum].people) {
$(".btns .btn.people").show();
} else {
$(".btns .btn.people").hide();
}
//Show pages tab if the class supports it, otherwise, hide it
if (dtps.classes[classNum].discussions) {
$(".btns .btn.discuss").show();
} else {
$(".btns .btn.discuss").hide();
}
//Show gradebook if the LMS and the class supports it, otherwise, hide it
const courseLMSGradebookAllowed = dtpsLMS.lmsGradebookAllowlist ? dtpsLMS.lmsGradebookAllowlist.includes(dtps.classes[classNum].id) : true;
if ((dtpsLMS.genericGradebook || (dtpsLMS.gradebook && courseLMSGradebookAllowed)) && dtps.classes[classNum].hasGradebook) {
$(".btns .btn.grades").show();
} else {
$(".btns .btn.grades").hide();
}
//Hide tabs if only one tab is visible
if ($("#dtpsTabBar .btn:visible").length < 2) {
$("#dtpsTabBar").hide();
}
//Show teacher
if (dtps.classes[classNum].teacher) {
$("#classInfo .teacher .teacherName").text(dtps.classes[classNum].teacher.name);
$("#classInfo .teacher .teacherImage").css("background-image", "url('" + dtps.classes[classNum].teacher.photoURL + "')");
$("#classInfo .teacher").show();
} else {
$("#classInfo .teacher").hide();
}
if (dtps.classes[classNum].homepage) {
$("#classInfo .homepage").show();
} else {
$("#classInfo .homepage").hide();
}
if (dtps.classes[classNum].videoMeetingURL) {
$("#classInfo .videoMeeting").attr("onclick", "window.open('" + dtps.classes[classNum].videoMeetingURL + "');");
$("#classInfo .videoMeeting").removeClass("shimmerParent");
$("#classInfo .videoMeeting").show();
} else if ((dtps.classes[classNum].videoMeetingURL !== null) && dtpsLMS.fetchMeetingURL) {
$("#classInfo .videoMeeting").attr("onclick", "");
$("#classInfo .videoMeeting").addClass("shimmerParent");
$("#classInfo .videoMeeting").show();
dtpsLMS.fetchMeetingURL(dtps.classes[classNum].lmsID).then(url => {
dtps.classes[classNum].videoMeetingURL = url;
if (dtps.selectedClass == classNum) {
if (url) {
$("#classInfo .videoMeeting").attr("onclick", "window.open('" + dtps.classes[classNum].videoMeetingURL + "');");
$("#classInfo .videoMeeting").removeClass("shimmerParent");
$("#classInfo .videoMeeting").show();
} else {
$("#classInfo .videoMeeting").hide();
}
}
});
} else {
$("#classInfo .videoMeeting").hide();
}
}
if (dtps.selectedContent !== "grades") $(".classContent").removeClass("fixedGradeSummary");
}
/**
* Displays the class homepage
*
* @param {number} num Class number to show the homepage for
*/
dtps.classHome = function (num) {
//Render loading screen
$(".card.details").html(/*html*/`
<i onclick="fluid.cards.close('.card.details')" class="fluid-icon close">close</i>
<h3 style="font-weight: bold;">${dtps.classes[num].subject} Homepage</h3>
<br />
<p>Loading...</p>
`);
//Fetch homepage from the LMS
dtpsLMS.fetchHomepage(dtps.classes[num].lmsID).then(homepage => {
if (homepage) {
//Get computed background and text color to style the iFrame with
var computedBackgroundColor = getComputedStyle($(".card.details")[0]).getPropertyValue("--cards");
var computedTextColor = getComputedStyle($(".card.details")[0]).getPropertyValue("--text");
if ($("body").hasClass("dark")) {
homepage = dtps.brightenTextForDarkMode(homepage, computedBackgroundColor);
}
//Generate a blob with the assignment body and get its data URL
var blob = new Blob([`
<base target="_blank" />
<link type="text/css" rel="stylesheet" href="https://cdn.jottocraft.com/CanvasCSS.css" media="screen,projection"/>
<style>body {background-color: ${computedBackgroundColor}; color: ${computedTextColor};</style>
${homepage}
`], { type: 'text/html' });
var homepageURL = window.URL.createObjectURL(blob);
$(".card.details").html(/*html*/`
<i onclick="fluid.cards.close('.card.details')" class="fluid-icon close">close</i>
<h4 style="font-weight: bold;">${dtps.classes[num].subject} Homepage</h4>
<br />
<div style="margin-top: 20px;" class="homepageBody">
<iframe id="homepageIframe" onload="dtps.iframeLoad('homepageIframe')" style="margin: 10px 0px; width: 100%; border: none; outline: none;" src="${homepageURL}" />
</div>
`);
fluid.cards(".card.details");
} else {
fluid.cards.close('.card.details');
dtps.error("Homepage unavailable", "homepage is either empty or undefined @ dtps.classHome");
}
}).catch(e => {
dtps.error("Couldn't load homepage", "Caught a promise rejection @ dtps.classHome", e);
})
}
/**
* Shows the gradebook using HTML from dtpsLMS.gradebook
*
* @param {string} classID Class ID
*/
dtps.showLMSGradebook = function (classID) {
//Get class index and set as selected class
var classNum = dtps.classes.map(course => course.id).indexOf(classID);
dtps.selectedClass = classNum;
//Set stream as the selected content
dtps.selectedContent = "grades";
$("#dtpsTabBar .btn").removeClass("active");
$("#dtpsTabBar .btn.grades").addClass("active");
//Load class color, name, etc.
dtps.presentClass(classNum);
//Ensure classes are shown in the sidebar
dtps.showClasses();
if (classNum == -1) {
//Class does not exist
dtps.error("The selected class doesn't exist", "classNum check failed @ dtps.showLMSGradebook");
}
//Show loading indicator
if ((dtps.selectedContent == "grades") && (dtps.selectedClass == classNum)) {
jQuery(".classContent").html(`<div class="spinner"></div>`);
}
//Function to load gradebook HTML
function renderGradebook() {
dtpsLMS.gradebook(dtps.classes[classNum]).then(html => {
if (html && (dtps.selectedClass == classNum) && (dtps.selectedContent == "grades")) {
$(".classContent").html(html);
if (dtpsLMS.gradebookDidRender) dtpsLMS.gradebookDidRender(dtps.classes[classNum]);
}
}).catch(function (err) {
dtps.error("Could not load the gradebook", "Caught promise rejection @ dtps.showLMSGradebook", err);
});
}
if (!fluid.externalScreens.stream) {
//Load assignment functions first
jQuery.getScript(dtps.baseURL + "/scripts/assignments.js", () => {
renderGradebook();
})
} else {
renderGradebook();
}
}
/**
* Shows a URL in the iFrame card
*
* @param {string} url The URL to load
*/
dtps.showIFrameCard = function (url) {
//Remove any previous URLs
$('#CardIFrame').attr('src', '');
//Show iFrame card
fluid.cards('.card.iFrameCard');
//Load new URL
$('#CardIFrame').attr('src', url);
}
/**
* Brightens text in an HTML string for dark mode
*
* @param {string} html The HTML to brighten text for
* @param {string} [bg] The background color that the HTML will be displayed on
* @returns {string} HTML string with brightened colors
*/
dtps.brightenTextForDarkMode = function (html, bg) {
//Use dark mode algorithm
if (!bg) bg = "black";
var fakeRoot = $('<div></div>').append(html);
//Reset text color to black if there is a background color set and no other color set
fakeRoot.find("*").filter(function () {
return ($(this).css("background-color") || $(this).css("background")) && !$(this).css("color") && !$(this).parents().filter(function () {
return $(this).css("color");
}).length;
}).toArray().forEach(element => {
//Reset to black
$(element).css("color", "#2D3B45");
});
//Brighten text colors
fakeRoot.find("span").filter(function () {
return $(this).css("color") && !($(this).css("background-color") || $(this).css("background")) && !$(this).parents().filter(function () {
return $(this).css("background-color") || $(this).css("background");
}).length;
}).toArray().forEach(element => {
//Brighten color
var brightness = tinycolor($(element).css("color")).getBrightness() / 255 * 100;
var targetBrightness = (100 - (tinycolor(bg).getBrightness() / 255 * 100)) - brightness;
var brightnessDiff = targetBrightness - brightness;
if (brightnessDiff > 0) {
$(element).css("color", "#" + tinycolor($(element).css("color")).brighten(Math.abs(brightnessDiff)).toHex());
} else {
$(element).css("color", "#" + tinycolor($(element).css("color")).darken(Math.abs(brightnessDiff)).toHex());
}
});
return fakeRoot.html();
}
/**
* Stores grade data locally for the previous grade feature
*
* @param classNum Class number to log grade
*/
dtps.logGrades = function (classNum) {
/*
Grade change vars:
gradeHistory-ID: current|previous (e.g. "A|B+" represents a change of B+ -> A)
if there is only one grade, it is the current one
*/
if (!dtps.classes[classNum].letter || (dtps.classes[classNum].letter == "...") || (dtps.classes[classNum].letter == "ERROR")) return;
if (window.localStorage.getItem("gradeHistory-" + dtps.classes[classNum].id)) {
var savedCurrent = window.localStorage.getItem("gradeHistory-" + dtps.classes[classNum].id).split("|")[0];
var savedPrevious = window.localStorage.getItem("gradeHistory-" + dtps.classes[classNum].id).split("|")[1];
if (savedCurrent !== dtps.classes[classNum].letter) {
//Grade change
window.localStorage.setItem("gradeHistory-" + dtps.classes[classNum].id, dtps.classes[classNum].letter + "|" + savedCurrent);
dtps.classes[classNum].previousLetter = savedCurrent;
}
if ((savedCurrent == dtps.classes[classNum].letter) && savedPrevious) {
//Previous grade is already saved
dtps.classes[classNum].previousLetter = savedPrevious;
}
} else {
//No history yet, store current grade
window.localStorage.setItem("gradeHistory-" + dtps.classes[classNum].id, dtps.classes[classNum].letter);
}
}
/**
* Opens the settings page
*
* @param {boolean} [forceRerenderDashboard] If this is true, the dashboard settings will be re-rendered
*/
dtps.settings = function (forceRerenderDashboard) {
//Render dashboard HTML if not already loaded
if (forceRerenderDashboard || ($("#leftDashboardColumn").attr("loaded") !== "true")) {
//Returns dashboard item HTML from a dashboard item array
function dashboardHTML(dashboardArray) {
return dashboardArray.map(dashboardItem => {
return /*html*/`
<div dashboardItem-id="${dashboardItem.id}" style="height: ${dashboardItem.size * 1.7 + 50}px" class="dashboardItem">
<h5>
<i class="fluid-icon">${dashboardItem.icon}</i>
<span>${dashboardItem.name}</span>
</h5>
</div>
`;
}).join("");
}
//Render dashboard items
$("#leftDashboardColumn").html(dashboardHTML(dtps.leftDashboard));
$("#rightDashboardColumn").html(dashboardHTML(dtps.rightDashboard));
//Make dashboard items sortable
$(".dashboardColumn").sortable({
connectWith: ".dashboardColumn",
update: function (e, ui) {
dtps.saveDashboardPrefs();
}
});
//Store loaded state to prevent reloading dashboard items
$("#leftDashboardColumn").attr("loaded", "true");
}
//Render grades tab in settings
dtps.renderGradesInSettings();
fluid.cards('.settingsCard');
}
/**
* Enables the reload warning when the user exists settings
*/
dtps.settingsReloadWarning = function () {
dtps.reloadPending = true;
}
/**
* Prompts for dangerous CBL enablement
*
* @param {boolean} [accepted] True if the disclaimer has been accepted
*/
dtps.dangerousCBLPrompt = function (accepted) {
const dangerousCBLEnabled = fluid.get("pref-dangerousCBL") == "true";
if (dangerousCBLEnabled) {
fluid.set("pref-dangerousCBL", "false");
dtps.settingsReloadWarning();
} else if (accepted) {
fluid.set("pref-dangerousCBL", "true");
dtps.settingsReloadWarning();
} else {
//Prompt for acceptance
fluid.alert("Allow CBL Features", [
`<style>.card.alert .body {max-height: none !important;}</style>`,
`<p>You must read and accept the following disclaimers to allow the usage of d.tech CBL features:</p>`,
`<ol style="line-height: 1.5;">`,
`<li>Starting June 2022, CBL features in Power+ are no longer actively updated. CBL features continue to be provided as-is for use at your own discretion.</li>`,
`<li>Power+ does not guarantee that CBL features will be up-to-date with the d.tech CBL spec. By proceeding, you accept all risks resulting from the usage of a potentially obsolete system.</li>`,
`<li>You are responsible for checking that the <a href="${dtps.cblSpec}">CBL spec Power+ is using</a> is correct for your use case.</li>`,
`<li><b>In short, these features may become (or are already) obsolete. <span style="background: red; color: white;">Proceed at your own risk.<span></b></li>`,
`</ol>`
].join(""), "report", [
{ name: "Accept", icon: "warning", action: () => dtps.dangerousCBLPrompt(true) },
{ name: "Cancel", icon: "close", action: () => fluid.exitAlert() }
], "red");
}
}
/**
* Renders the grades tab in settings
*/
dtps.renderGradesInSettings = function () {
//Render d.tech dangerous CBL settings
if (dtpsLMS.dtech) {
$("#classCBLChex").html(dtps.classes.map(course => {
return (
`<div>
<div onclick="fluid.set('pref-enabledCBLFor${course.id}'); dtps.settingsReloadWarning();" class="checkbox pref-enabledCBLFor${course.id}"><i class="fluid-icon">check</i></div>
<div class="label">${course.name}</div>
</div><br />`
);
}));
}
//Add CBL area toggle listener
if (fluid.get("pref-dangerousCBL") == "true") { jQuery('#cblSwitchesArea').show(); }
document.addEventListener("pref-dangerousCBL", function (e) {
if ((e.detail == "true") || (e.detail == true)) {
//dangerous CBL has been enabled, show class toggle area
jQuery('#cblSwitchesArea').show();
} else {
//dangerous has been disabled, hide class toggle area
jQuery('#cblSwitchesArea').hide();
}
});
//Render class grade bars
$("#classGradeBars").html(dtps.classes.map(course => {
//Percentages below are for visualization purposes only
var percentage = 0;
if (course.letter == "A") percentage = 100;
if (course.letter == "A-") percentage = 91;
if (course.letter == "B+") percentage = 88;
if (course.letter == "B") percentage = 85;
if (course.letter == "B-") percentage = 81;
if (course.letter == "C+") percentage = 78;
if (course.letter == "C") percentage = 75;
return (
course.letter ? `<div style="cursor: auto; background-color: var(--elements);" class="progressBar big">
<div style="color: white;" class="progressLabel">${course.subject} (${course.letter})</div>
<div class="progress" style="background-color: ${course.color}; width: calc(${percentage}% - 300px);"></div></div>` : ""
)
}));
//Calculate GPA
var sum = 0;
var values = 0;
dtps.classes.forEach(course => {
if (course.letter == "...") {
sum = null;
} else if (course.letter && (sum !== null)) {
var points = 0;
if (course.letter == "A") points = 4;
if (course.letter == "A-") points = 3.7;
if (course.letter == "B+") points = 3.3;
if (course.letter == "B") points = 3;
if (course.letter == "B-") points = 2.7;
if (course.letter == "C+") points = 2.3;
if (course.letter == "C") points = 2.0;
if (course.letter == "I") points = 0;
sum += points;
values++;
}
})
if (sum == null) {
$("#dtpsGpaText").text("...");
} else if (values === 0) {
$("#dtpsGpaText").text("Not available");
} else {
$("#dtpsGpaText").text((sum / values).toFixed(1));
}
//Initialize Fluid UI again
fluid.init();
}
/**
* Saves dashboard prefrences
*/
dtps.saveDashboardPrefs = function () {
var dashboardPrefs = [];
//Add dashboard items
for (var i = 0; i < $(".dashboardColumn").children().length; i++) {
//Get HTML item
var child = $($(".dashboardColumn").children()[i]);
//Add item to dashboardPrefs array
dashboardPrefs.push({
id: child.attr("dashboardItem-id"),
side: child.parent().attr("id").includes("left") ? "left" : "right"
});
}
//Save to local storage
window.localStorage.setItem("dtpsDashboardItems", JSON.stringify(dashboardPrefs));
//Reload dashboard items
dtps.loadDashboardPrefs();
}
/**
* Resets dashboard prefrences
*/
dtps.resetDashboardPrefs = function () {
//Remove dashboard pref from localStorage
window.localStorage.removeItem("dtpsDashboardItems");
//Reload dashboard items
dtps.loadDashboardPrefs();
//Re-render dashboard
dtps.settings(true);
}
/**
* Redirects to DTPS classic edition
*/
dtps.classicEntry = function () {
var classicName = ["P", "r", "o", "j", "e", "c", "t", " ", "D", "T", "P", "S"];
var overwrittenHTML = ["P", "o", "w", "e", "r", "+"];
if (dtps.classicStep == undefined) dtps.classicStep = -10;
dtps.classicStep++;
if (dtps.classicStep == 12) window.location.href = "/power+?classicEdition=true";
for (var i = 0; i < dtps.classicStep; i++) {
overwrittenHTML[i] = classicName[i];
}
$("#dtpsAboutName").html(overwrittenHTML.join(""));
}
/**
* Loads dashboard prefrences
*/
dtps.loadDashboardPrefs = function () {
//Define left and right dashboard columns
dtps.leftDashboard = [];
dtps.rightDashboard = [];
//Add items to dashboard columns
if (window.localStorage.dtpsDashboardItems) {
//Add from user prefs
//Parse JSON
var dashboardPref = JSON.parse(window.localStorage.dtpsDashboardItems);
//Generate arrays of dashboard item IDs
var dashboardIDs = dtps.dashboardItems.map(item => item.id);
//Add items to dashboard according to prefs
dashboardPref.forEach(dashboardItemPref => {
if (dashboardIDs.includes(dashboardItemPref.id)) {
//Get item index based on dashboardIDs array
var itemIndex = dashboardIDs.indexOf(dashboardItemPref.id);
//Get item from dtps.dashboardItems array
var dashboardItem = dtps.dashboardItems[itemIndex];
//Add item to dashboard
if (dashboardItemPref.side == "left") {
dtps.leftDashboard.push(dashboardItem);
} else if (dashboardItemPref.side == "right") {
dtps.rightDashboard.push(dashboardItem);
}
}
});
} else {
//Add from defaults
dtps.dashboardItems.forEach(dashboardItem => {
if (dashboardItem.defaultSide == "left") {
dtps.leftDashboard.push(dashboardItem);
} else if (dashboardItem.defaultSide == "right") {
dtps.rightDashboard.push(dashboardItem);
}
})
}
}
/**
* Renders initial static DTPS HTML
*/
dtps.render = function () {
//Remove all existing link tags and LMS head content
if (!dtpsLMS.isDemoLMS) {
$("link:not(.dtpsHeadItem)").remove();
$("head *:not(.dtpsHeadItem)").remove();
}
//Set default body classes
$("body").attr("class", "dark showThemeWindows hasSidebar hasNavbar dashboard");
//Set document title and favicon
document.title = "Power+";
jQuery("<link/>", {
rel: "shortcut icon",
type: "image/png",
href: dtps.baseURL + "/favicon.png"
}).appendTo("head");
//Remove existing LMS HTML (excluding DTPS loading screen HTML)
jQuery("body *:not(#dtpsNativeOverlay):not(#dtpsNativeOverlay *)").remove();
//Render HTML
jQuery("body").append(/*html*/`
<div class="sidebar"></div>
<div class="navbar">
<div class="logo">
<img src="${dtps.baseURL + "/icon.svg"}" />
<h4>${dtps.baseURL == "https://dev.dtps.jottocraft.com" ? "Power+ (dev)" : "Power+"}</h4>
</div>
${dtps.unstable && !dtpsLMS.isDemoLMS ? `
<div id="dtpsUnstable" class="navitem">
<i style="font-size: 16px;" class="fluid-icon">warning</i>
<span style="font-weight: bold; font-size: 10px;">THIS IS AN UNSTABLE VERSION OF POWER+. USE AT YOUR OWN RISK.</span>
</div>
` : ""}
<div class="items" id="dtpsFixedSearch">
<i class="inputIcon fluid-icon">search</i>
<input id="dtpsMainSearchBox" style="margin: 0px; width: 500px;" type="search" class="inputIcon filled" placeholder="Search" />
</div>
<div class="items" style="float: right;">
<a style="display: none;" id="dtpsUnreadCount" target="_blank" href="/conversations" class="navitem">
<i class="fluid-icon">inbox</i>
<span></span>
</a>
<div class="navitem" onclick="dtps.settings();">
<i class="fluid-icon">settings</i>
<span>Settings</span>
</div>
</div>
</div>
<div id="dtpsSearchResults" class="card acrylicMaterial" style="display: none;">
<h5 id="dtpsSearchStatus"><i class="fluid-icon">search</i> <span>Search</span></h5>
<div id="dtpsSearchData" style="display: none;"></div>
<div id="dtpsSearchInfo">
<p>By defualt, Power+ will search based on the page you're on. You can use the keywords below for more advanced searches:</p>
<div class="grid samesize">
<div class="item">
<p><i class="fluid-icon">library_books</i> type:coursework</p>
<p><i class="fluid-icon">view_module</i> type:module</p>
<p><i class="fluid-icon">assignment</i> type:assignment</p>
<p><i class="fluid-icon">assessment</i> type:grade</p>
<p><i class="fluid-icon">announcement</i> type:announcement</p>
</div>
<div class="item">
<p><i class="fluid-icon">home</i> type:homepage</p>
<p><i class="fluid-icon">insert_drive_file</i> type:page</p>
<p><i class="fluid-icon">forum</i> type:discussion</p>
<p><i class="fluid-icon">people</i> type:person</p>
<div class="divider"></div>
<p><i class="fluid-icon">class</i> class:English</p>
</div>
</div>
</div>
</div>
<div class="headerArea classImage">
<img style="display: none;" />
<div class="content">
<h1 id="headText"><i class="fluid-icon">dashboard</i> <span>Dashboard</span></h1>
<div id="classInfo" style="min-height: 18px;">
<p class="teacher" style="display: none;">
<span class="teacherImage"></span> <span class="teacherName"></span>
</p>
<p onclick="dtps.classHome(dtps.selectedClass);" class="homepage" style="cursor: pointer; display: none;">
<i class="fluid-icon">home</i> <span>Homepage</span>
</p>
<p class="videoMeeting" style="cursor: pointer; display: none;">
<i class="fluid-icon">videocam</i> <span>Meeting</span>
</p>
<p class="contentLabel" style="display: none;">
<i class="fluid-icon"></i> <span></span>
</p>
</div>
<div style="display: none;" id="dtpsTabBar" class="btns">
<button init="true" onclick="if (dtps.classes[dtps.selectedClass].modules && (window.localStorage.getItem('courseworkPref-' + dtps.classes[dtps.selectedClass].id) == 'moduleStream')) { fluid.screen('moduleStream', dtps.classes[dtps.selectedClass].id); } else { fluid.screen('stream', dtps.classes[dtps.selectedClass].id); }" class="btn stream">
<i class="fluid-icon">library_books</i>
Coursework
</button>
<button init="true" onclick="fluid.screen('people', dtps.classes[dtps.selectedClass].id);" class="btn people">
<i class="fluid-icon">people</i>
People
</button>
<button init="true" onclick="fluid.screen('discussions', dtps.classes[dtps.selectedClass].id);" class="btn discuss">
<i class="fluid-icon">forum</i>
Discussions
</button>
<button init="true" onclick="fluid.screen('pages', dtps.classes[dtps.selectedClass].id);" class="btn pages">
<i class="fluid-icon">insert_drive_file</i>
Pages
</button>
<button init="true" onclick="fluid.screen('gradebook', dtps.classes[dtps.selectedClass].id);" class="btn grades">
<i class="fluid-icon">assessment</i>
Gradebook
</button>
</div>
</div>
</div>
<!-- Class content area -->
<div class="classContent">
<div class="spinner"></div>
</div>
<!-- Settings card (its inner HTML is added later) -->
<div style="height: 100%; overflow: auto !important;" class="card withnav focus close container settingsCard"></div>
<!-- Changelog card -->
<div style="border-radius: 30px;" class="card focus changelog close container">
<i onclick="fluid.cards.close('.card.changelog')" class="fluid-icon close">close</i>
<h3>What's new in Power+</h3>
<h5>Loading...</h5>
</div>
<!-- General details card for assignments, outcomes, class info, etc. -->
<div style="border-radius: 30px;" class="card focus details close container">
<i onclick="fluid.cards.close('.card.details')" class="fluid-icon close">close</i>
<p>An error occured</p>
</div>
<!-- General iFrame card (with white background) -->
<div style="border-radius: 30px; top: 50px; background-color: white; color: black;" class="card focus close iFrameCard container">
<i style="color: black !important;" onclick="fluid.cards.close('.card.iFrameCard'); $('#CardIFrame').attr('src', '');" class="fluid-icon close">close</i>
<br /><br />
<iframe style="width: 100%; height: calc(100vh - 175px); border: none;" id="CardIFrame"></iframe>
</div>
`);
$("#dtpsMainSearchBox").on("focus", function () {
$("#dtpsSearchResults").show();
});
$("#dtpsMainSearchBox").on("blur", function () {
$("#dtpsSearchResults").hide();
});
$("#dtpsMainSearchBox").on("input", function () {
dtps.setSearchBox();
});
$(document).on("keydown", "#dtpsMainSearchBox", function (e) {
if (e.key == "Enter") {
//Validate input and tags
var term = $("#dtpsMainSearchBox").val().replace(/(type|class):[a-z]*/gi, "").trim();
if ($("#dtpsMainSearchBox").attr("data-search-type") && $("#dtpsMainSearchBox").attr("data-dtps-course")) {
fluid.screen("search", {
term: term,
type: $("#dtpsMainSearchBox").attr("data-search-type"),
course: $("#dtpsMainSearchBox").attr("data-dtps-course"),
ctxType: $("#dtpsMainSearchBox").attr("data-ctx-type"),
ctxCourse: $("#dtpsMainSearchBox").attr("data-ctx-course")
});
$("#dtpsMainSearchBox").blur();
}
}
});
}
/**
* Sets the search box text based on the current page or keywords
*/
dtps.setSearchBox = function () {
var value = $("#dtpsMainSearchBox").val() || "";
var type = null;
var course = dtps.selectedClass == "dash" ? "dash" : dtps.classes[dtps.selectedClass];
var icon = null;
var error = false;
var autoType = null;
var autoCourse = dtps.selectedClass == "dash" ? "dash" : dtps.classes[dtps.selectedClass];
//Get automatic type from selected content
if (dtps.selectedContent == "people") type = "people";
if (dtps.selectedContent == "discuss") type = "discussions";
if (dtps.selectedContent == "pages") type = "pages";
if (dtps.selectedContent == "grades") type = "grades";
if ((dtps.selectedClass == "dash") || (dtps.selectedContent == "stream") || (dtps.selectedContent == "moduleStream")) type = "coursework";
autoType = type;
//Reuse auto type if on search
if (dtps.selectedClass == "search") {
type = $("#dtpsMainSearchBox").attr("data-ctx-type");
course = $("#dtpsMainSearchBox").attr("data-ctx-course");
if (!isNaN(Number(course))) course = dtps.classes[course];
}
//Check for type override from search box
if (value.split(" ").includes("type:coursework")) type = "coursework";
if (value.split(" ").includes("type:homepage")) type = "homepages";
if (value.split(" ").includes("type:page")) type = "pages";
if (value.split(" ").includes("type:discussion")) type = "discussions";
if (value.split(" ").includes("type:grade")) type = "grades";
if (value.split(" ").includes("type:person")) type = "people";
if (value.split(" ").includes("type:assignment")) type = "assignments";
if (value.split(" ").includes("type:module")) type = "modules";
if (value.split(" ").includes("type:announcement")) type = "announcements";
if (value.split(" ").includes("type:all")) type = "everything";
//Get icon from final type
if (type == "coursework") icon = "library_books";
if (type == "people") icon = "people";
if (type == "discussions") icon = "forum";
if (type == "pages") icon = "insert_drive_file";
if (type == "grades") icon = "assessment";
if (type == "assignments") icon = "assignment";
if (type == "modules") icon = "view_module";
if (type == "homepages") icon = "home";
if (type == "announcements") icon = "announcement";
if (type == "everything") icon = "warning";
//Check for course override from search box
if (value.includes("class:")) {
var partialSubject = value.split("class:")[1].split(" ")[0].toLowerCase();
if (partialSubject == "all") {
course = "all";
} else if (partialSubject) {
dtps.classes.forEach(c => {
if (c.subject.toLowerCase().includes(partialSubject)) {
course = c;
}
});
}
}
//Check if the course supports the type
if ((course !== "dash") && (course !== "all")) {
if ((type == "pages") && !course.pages) error = true;
if ((type == "discussions") && !course.discussions) error = true;
if ((type == "modules") && !course.modules) error = true;
if ((type == "homepages") && !course.homepage) error = true;
if ((type == "people") && !course.people) error = true;
}
if ((course == "dash") || (course == "all")) {
$("#dtpsSearchStatus i").text(icon);
$("#dtpsSearchStatus span").text("Search " + (type == "everything" ? type : "all " + type));
$("#dtpsMainSearchBox").attr("placeholder", "Search " + (type == "everything" ? type : "all " + type));
$("#dtpsMainSearchBox").attr("data-dtps-course", "all");
$("#dtpsMainSearchBox").attr("data-search-type", type);
} else if (error) {
$("#dtpsSearchStatus i").text("error");
$("#dtpsSearchStatus span").html(`Cannot search for ${type} in <b style="color: ${course.color};">${course.subject}</b>`);
$("#dtpsMainSearchBox").attr("placeholder", "Cannot search for " + type + " in " + course.subject);
$("#dtpsMainSearchBox").attr("data-dtps-course", "");
$("#dtpsMainSearchBox").attr("data-search-type", "");
} else if (course || true) {
$("#dtpsSearchStatus i").text(icon);
$("#dtpsSearchStatus span").html(`Search ${type} in <b style="color: ${course.color};">${course.subject}</b>`);
$("#dtpsMainSearchBox").attr("placeholder", "Search " + type + " in " + course.subject);
$("#dtpsMainSearchBox").attr("data-dtps-course", course.num);
$("#dtpsMainSearchBox").attr("data-search-type", type);
}
//Set auto type
if (dtps.selectedClass !== "search") {
$("#dtpsMainSearchBox").attr("data-ctx-type", autoType);
$("#dtpsMainSearchBox").attr("data-ctx-course", autoCourse == "dash" ? "dash" : autoCourse.num);
}
}
/**
* Renders basic content after the user has been loaded
*/
dtps.renderLite = function () {
//Add hideGrades body class if enabled and preference listener
if (fluid.get("pref-hideGrades") == "true") { jQuery('body').addClass('hidegrades'); }
document.addEventListener("pref-hideGrades", function (e) {
if ((e.detail == "true") || (e.detail == true)) {
//hideGrades has been enabled, add class to body to hide grades via CSS
jQuery('body').addClass('hidegrades');
} else {
//hideGrades has been disabled, remove class from body to show grades
jQuery('body').removeClass('hidegrades');
}
});
//Add alternateFont body class if enabled and preference listener
if (fluid.get("pref-alternateFont") == "true") { jQuery('body').addClass('alternateFont'); }
document.addEventListener("pref-alternateFont", function (e) {
if ((e.detail == "true") || (e.detail == true)) {
//alternateFont has been enabled, add class to body to change font
jQuery('body').addClass('alternateFont');
} else {
//alternateFont has been disabled, remove class from body to revert font change
jQuery('body').removeClass('alternateFont');
}
});
/**
* A callback for blocking Fluid UI card closes
*/
dtps.closeScreen = function () {
if (dtps.reloadPending) {
jQuery("body").addClass("requiredModal");
fluid.alert("Reload required", "Some of the settings you've changed require a reload to take effect.", "refresh", [
{ name: "Reload Power+", icon: "refresh", action: () => window.location.reload() }
], "var(--theme)");
return true;
}
return false;
}
//Render settings card
var baseHost = new URL(dtps.baseURL).hostname;
jQuery(".card.settingsCard").html(/*html*/`
<i onclick="fluid.cards.close('.card.settingsCard')" class="fluid-icon close">close</i>
<div style="position: fixed; height: calc(100% - 100px);" class="sidenav">
<div class="title">
<img src="${dtps.baseURL + "/icon.svg"}" style="width: 50px;vertical-align: middle;padding: 7px; padding-top: 14px;" />
<div style="vertical-align: middle; display: inline-block;">
<h5 style="font-weight: bold;display: inline-block;vertical-align: middle;">Power+</h5>
<p>
<span style="vertical-align: middle;">${dtps.readableVer}</span>
<span style="margin: 0 1.5px; display: inline-block; color: var(--secText);"></span>
<a target="_blank" style="color: var(--lightText); font-size: 12px; vertical-align: middle;" href="https://jottocraft.com/feedback?key=dtps"><i class="fluid-icon" style="vertical-align: middle; font-size: 14px; margin: 0 1px 0 0;">feedback</i> Feedback</a>
</p>
${(dtps.unstable ? `<p style="font-size: 12px; color: red; margin-top: 6px;">UNSTABLE</p>` : "")}
</div>
</div>
<div onclick="$('.abtpage').hide();$('.abtpage.settings').show();" class="item active">
<i class="fluid-icon">settings</i> Settings
</div>
<div onclick="$('.abtpage').hide();$('.abtpage.theme').show();" class="item">
<i class="fluid-icon">format_paint</i> Theme
</div>
${dtpsLMS.dtech ? /*html*/`
<div onclick="$('.abtpage').hide();$('.abtpage.cblCalc').show();" class="item">
<i class="fluid-icon">calculate</i> CBL
</div>
` : ``}
<div onclick="$('.abtpage').hide();$('.abtpage.grades').show();" class="item">
<i class="fluid-icon">assessment</i> GPA
</div>
<div onclick="$('.abtpage').hide();$('.abtpage.dashboard').show();" class="item">
<i class="fluid-icon">dashboard</i> Dashboard
</div>
${dtps.env == "dev" ? /*html*/`
<div onclick="$('.abtpage').hide();$('.abtpage.debugging').show();" class="item">
<i class="fluid-icon">bug_report</i> Debugging
</div>
<div onclick="$('.abtpage').hide();$('.abtpage.experiments').show();" class="item">
<i class="fluid-icon">science</i> Experiments
</div>
` : ``}
<div onclick="$('.abtpage').hide();$('.abtpage.about').show();" class="item abt">
<i class="fluid-icon">info</i> About
</div>
</div>
<div style="min-height: 100%" class="content">
<div class="abtpage settings">
<h5><b>Settings</b></h5>
<br />
<p>Sidebar</p>
<div onclick="fluid.set('pref-hideGrades');" class="switch pref-hideGrades"><span class="head"></span></div>
<div class="label"><i class="fluid-icon">visibility_off</i> Hide class grades</div>
<br /><br />
<p>Classes</p>
<div onclick="fluid.set('pref-fullNames'); dtps.settingsReloadWarning();" class="switch pref-fullNames"><span class="head"></span></div>
<div class="label"><i class="fluid-icon">title</i> Show full class names</div>
<br /><br />
<div onclick="fluid.set('pref-hideClassImages'); fluid.screen();" class="switch pref-hideClassImages"><span class="head"></span></div>
<div class="label"><i class="fluid-icon">image</i> Hide class images</div>
${dtpsLMS.shortName === "Canvas" ? /*html*/`
<br /><br />
<div onclick="fluid.set('pref-showAllClasses'); dtps.settingsReloadWarning();" class="switch pref-showAllClasses"><span class="head"></span></div>
<div class="label"><i class="fluid-icon">visibility</i> Show all published classes, including those which may have ended</div>
` : ""}
<br /><br />
<p>Assignments</p>
<div onclick="fluid.set('pref-unusualDueDates'); fluid.screen();" class="switch pref-unusualDueDates active"><span class="head"></span></div>
<div class="label"><i class="fluid-icon">highlight</i> Highlight outlying due dates</div>
<div id="dtpsPrereleaseTesting" style="${window.localStorage.prereleaseEnabled || (dtps.env == "dev") || window.localStorage.githubRepo || window.localStorage.externalReleaseURL ? "" : "display: none;"}">
<br /><br />
<p>Prerelease testing</p>
<div>
<div class="btns row small">
<button
onclick="window.localStorage.setItem('dtpsLoaderPref', 'prod')"
class="btn ${!["dev", "github", "external", "local"].includes(window.localStorage.dtpsLoaderPref) ? "active" : ""}">
<i class="fluid-icon">label</i> Production
</button>
<!-- check dtps.init before uncommenting <button
onclick="window.localStorage.setItem('dtpsLoaderPref', 'dev')"
class="btn ${window.localStorage.dtpsLoaderPref == "dev" ? "active" : ""}">
<i class="fluid-icon">feedback</i> Dev
</button>-->
${window.localStorage.githubRepo ? /*html*/`
<button
onclick="window.localStorage.setItem('dtpsLoaderPref', 'github')"
class="btn ${window.localStorage.dtpsLoaderPref == "github" ? "active" : ""}">
<i class="fluid-icon">account_tree</i> Branch
</button>
` : ``}
${window.localStorage.externalReleaseURL ? /*html*/`
<button
onclick="window.localStorage.setItem('dtpsLoaderPref', 'external')"
class="btn ${window.localStorage.dtpsLoaderPref == "external" ? "active" : ""}">
<i class="fluid-icon">public</i> External
</button>
` : ``}
<button
onclick="window.localStorage.setItem('dtpsLoaderPref', 'local')"
class="btn ${window.localStorage.dtpsLoaderPref == "local" ? "active" : ""}">
<i class="fluid-icon">developer_board</i> Local
</button>
</div>
</div>
</div>
</div>
<div style="display: none;" class="abtpage theme">
<h5><b>Theme</b></h5>
<br />
<div class="themeSelectionUI flat"></div>
</div>
<div style="display: none;" class="abtpage cblCalc">
<h5><b>d.tech CBL</b></h5>
<div>
<p><span style="width: 32px; display: inline-block; text-align: center;">⚠️</span> CBL features are no longer actively updated and may become (or are already) obsolete. Use at your own risk.</p>
<p><span style="width: 32px; display: inline-block; text-align: center;">ℹ️</span> CBL features follow the <a href="${dtps.cblSpec}">August 2021</a> specification.</p>
</div>
<br />
<div onclick="dtps.dangerousCBLPrompt();" class="switch pref-dangerousCBL"><span class="head"></span></div>
<div class="label"><i class="fluid-icon">functions</i> Allow CBL features</div>
<br /><br /><br />
<div id="cblSwitchesArea" style="display: none;">
<div class="divider"></div>
<br />
<p>Check the boxes below to opt-in to CBL features for each class you'd like to use them with:</p>
<br />
<div id="classCBLChex"></div>
</div>
</div>
<div style="display: none;" class="abtpage grades">
<h5><b>GPA</b></h5>
<br />
<div>
<h6>Unweighted GPA: <b id="dtpsGpaText">...</b></h6>
<p style="color: var(--secText);"><i>This GPA calculation is based on the letter grades shown below and is not official.</i></p>
</div>
<br />
<div id="classGradeBars"></div>
</div>
<div style="display: none;" class="abtpage dashboard">
<h5><b>Dashboard</b></h5>
<p>You can rearrange the items shown on the dashboard by dragging them below. You might have to reload Power+ for changes to take effect.</p>
<button onclick="dtps.resetDashboardPrefs();" class="btn small"><i class="fluid-icon">refresh</i> Reset dashboard layout</button>
<br />
<div id="leftDashboardColumn" class="dashboardColumn"></div>
<div id="rightDashboardColumn" class="dashboardColumn"></div>
</div>
${dtps.env == "dev" ? /*html*/`
<div style="display: none;" class="abtpage debugging">
<h5><b>Debugging</b></h5>
<p>These settings are for development purposes only and might break Power+. Use at your own risk.</p>
<div>
<button onclick="dtps.firstrun()" class="btn small"><i class="fluid-icon">web_asset</i> Show firstrun screen</button>
</div>
<div>
<button onclick="dtps.changelog(undefined, true);" class="btn small"><i class="fluid-icon">description</i> Show draft changelog</button>
</div>
<br /><br />
<h5>Release configuration</h5>
<button onclick="['dtpsLoaderPref', 'prereleaseEnabled', 'githubRepo', 'externalReleaseURL', 'dtpsLMSOverride'].forEach(k => window.localStorage.removeItem(k)); window.location.reload();" class="btn small"><i class="fluid-icon">cancel</i> Clear release configurations</button>
<br /><br />
<div>
<input id="dtpsGithubRepo" value="${window.localStorage.githubRepo || ""}" placeholder="Branch GitHub repo" />
<button class="btn small" onclick="window.localStorage.setItem('githubRepo', $('#dtpsGithubRepo').val())"><i class="fluid-icon">save</i> Save</button>
</div>
<div>
<input id="dtpsExternalReleaseURL" value="${window.localStorage.externalReleaseURL || ""}" placeholder="External release URL" />
<button class="btn small" onclick="window.localStorage.setItem('externalReleaseURL', $('#dtpsExternalReleaseURL').val())"><i class="fluid-icon">save</i> Save</button>
</div>
<div>
<input id="dtpsLMSOverride" value="${window.localStorage.dtpsLMSOverride || ""}" placeholder="LMS override" />
<button class="btn small" onclick="window.localStorage.setItem('dtpsLMSOverride', $('#dtpsLMSOverride').val())"><i class="fluid-icon">save</i> Save</button>
</div>
<br />
<h5>Behavior Overrides</h5>
<br />
<div onclick="fluid.set('pref-debuggingGenericGradebook')" class="switch pref-debuggingGenericGradebook"><span class="head"></span></div>
<div class="label">Always use the generic gradebook</div>
</div>
<div style="display: none;" class="abtpage experiments">
<h5><b>Experiments</b></h5>
<p>These settings control how Power+ behaves. Changing these settings may enable unsupported and/or unfinished features that can break Power+. Use at your own risk.</p>
<button onclick="Object.keys(dtps.remoteConfig).forEach(k => window.localStorage.removeItem('dtpsRemoteConfig-' + k));" class="btn small"><i class="fluid-icon">cancel</i> Clear Overrides</button>
<br /><br />
${Object.keys(dtps.remoteConfig).map(k => (`
<div>
<p>${k} ${window.localStorage.getItem("dtpsRemoteConfig-" + k) ? " <b>(overridden)</b>" : ""}</p>
<input value="${dtps.remoteConfig[k]}" placeholder="Value" />
<button class="btn small" onclick="window.localStorage.setItem('dtpsRemoteConfig-${k}', $(this).siblings('input').val())"><i class="fluid-icon">save</i> Save</button>
</div>
`)).join("")}
</div>
` : ``}
<div style="display: none;" class="abtpage about">
<h5><b>About</b></h5>
<div class="card" style="padding: 10px 20px; box-shadow: none !important; border: 2px solid var(--elements); margin-top: 20px;">
<img src="${dtps.baseURL + "/icon.svg"}" style="height: 50px; margin-right: 10px; vertical-align: middle; margin-top: 20px;" />
<div style="display: inline-block; vertical-align: middle;">
<h4 id="dtpsAboutName" onclick="dtps.classicEntry()" style="font-weight: bold; font-size: 32px; margin-bottom: 0px; user-select: none;">${dtps.baseURL == "https://dev.dtps.jottocraft.com" ? "Power+ (dev)" : "Power+"}</h4>
<div style="font-size: 16px; margin-top: 5px;">
${dtps.readableVer}
<div style="display: inline-block;margin: 0px 5px;font-size: 12px;">${baseHost !== "powerplus.app" ? `(from ${baseHost})` : ""}</div>
</div>
</div>
<div style="margin-top: 15px; margin-bottom: 7px;"><a onclick="dtps.changelog();" style="color: var(--lightText); margin: 0px 5px;" href="#"><i class="fluid-icon" style="vertical-align: middle">update</i> Changelog</a>
<a style="color: var(--lightText); margin: 0px 5px;" href="mailto:hello@jottocraft.com"><i class="fluid-icon" style="vertical-align: middle">email</i> Contact me: hello@jottocraft.com</a>
</div>
</div>
<div class="card" style="padding: 10px 20px; box-shadow: none !important; border: 2px solid var(--elements); margin-top: 20px;">
<div style="margin-top: 12px;">
<img src="${dtps.user.photoURL}" style="height: 50px; margin-right: 10px; vertical-align: middle; border-radius: 50%;" />
<div style="display: inline-block; vertical-align: middle;">
<h4 style="font-weight: bold; font-size: 32px; margin: 0px;">${dtps.user.name} <span style="font-size: 12px; line-height: 0px;">${dtps.user.id}</span></h4>
</div>
</div>
<div style="margin-top: 15px; margin-bottom: 7px;">
<a style="color: var(--lightText); margin: 0px 5px;" href="/logout"><i class="fluid-icon" style="vertical-align: middle">exit_to_app</i> Logout</a>
</div>
</div>
<div class="card" style="padding: 10px 20px; box-shadow: none !important; border: 2px solid var(--elements); margin-top: 20px;">
<img src="${dtpsLMS.logo}" style="height: 50px; margin-right: 10px; vertical-align: middle; margin-top: 20px; border-radius: 50%;" />
<div style="display: inline-block; vertical-align: middle;">
<h4 style="font-weight: bold; font-size: 32px; margin-bottom: 0px;">${dtpsLMS.name}</h4>
${dtpsLMS.description ? `
<div style="font-size: 16px; margin-top: 5px;">${dtpsLMS.description}</div>
` : ``}
</div>
<div style="margin-top: 15px; margin-bottom: 7px;">
<a style="color: var(--lightText); margin: 0px 5px;" href="${dtpsLMS.source}"><i class="fluid-icon" style="vertical-align: middle">code</i> LMS integration source code</a>
</div>
</div>
<div class="card advancedOptions" style="padding: 8px 16px; box-shadow: none !important; border: 2px solid var(--elements); margin-top: 20px; ${dtps.env == "dev" ? `` : `display: none;`}">
<div style="display: inline-block; vertical-align: middle;">
<h4 style="font-weight: bold; font-size: 28px; margin-bottom: 0px;">Advanced Options</h4>
</div>
<br /><br />
<div onclick="fluid.set('pref-alternateFont')" class="switch pref-alternateFont"><span class="head"></span></div>
<div class="label"><i class="fluid-icon">font_download</i> Use Alternate Font</div>
<br /><br />
<div style="margin-top: 15px; margin-bottom: 7px;">
<a style="color: var(--lightText); margin: 0px 5px; cursor: pointer;" onclick="dtps.clearData();"><i class="fluid-icon" style="vertical-align: middle">refresh</i> Reset Power+</a>
<a style="color: var(--lightText); margin: 0px 5px; cursor: pointer;" onclick="if (confirm('Prerelease versions of Power+ are often untested and can break or display incorrect information. Are you sure you want to continue?')) {window.localStorage.prereleaseEnabled = 'true'; $('#dtpsPrereleaseTesting').show(); alert('Prerelease versions can be enabled by going to Settings -> Prerelease testing');}"><i class="fluid-icon" style="vertical-align: middle">feedback</i> Test prerelease versions</a>
</div>
</div>
<br />
${dtps.env == "dev" ? `` : `<p style="cursor: pointer; color: var(--secText, gray)" onclick="$('.advancedOptions').show(); $(this).hide();" class="advOp">Show advanced options</p>`}
<div style="text-align: center; padding: 50px 0px;">
<a href="https://jottocraft.com">
<img style="height: 38px; margin-right: 10px; vertical-align: middle;" src="https://cdn.jottocraft.com/images/footerImage.png" />
<h5 style="display: inline-block; vertical-align: middle; color: var(--text);">jottocraft</h5>
</a>
<p>(c) jottocraft 2018-2023. This project is <a href="https://jottocraft.com/oss">open source</a>.
</div>
</div>
</div>
`);
//Load Fluid UI
fluid.onLoad();
//Render sidebar
dtps.showClasses();
//Remove loading screen styles
jQuery("#dtpsNativeOverlay style").remove();
//Fade out loading screen
jQuery("#dtpsNativeOverlay").css("opacity", "0");
//Remove loading screen after the animation is complete
setTimeout(() => jQuery("#dtpsNativeOverlay").remove(), 200);
}
//Start loading DTPS
dtps.init();
//LMS Integration documentation -------------------------------------------------------------------------------------
/**
* @namespace dtpsLMS
* @description Global DTPS LMS integration object. All LMS-related tasks, such as fetching data, are handled by this object. This is always loaded first.
* @global
* @property {string} name Full LMS name
* @property {string} [shortName] Short LMS name
* @property {string} legalName Legal name (or names) to show in the "Welcome to Power+" disclaimer
* @property {string} [description] A short description of the LMS integration provided
* @property {string} logo LMS logo image URL
* @property {string} url URL to the LMS' website
* @property {string} source URL to the LMS integration's source code
* @property {string} [inboxURL] URL to the LMS inbox. Required only if dtpsLMS.fetchUnreadMessageCount count is implemented
* @property {boolean} [dtech] True if this LMS is d.tech
* @property {boolean} [institutionSpecific] True if the LMS is designed for a specific institution instead of a broader LMS
* @property {boolean|string[]} [useRubricGrades] True if DTPS should use rubric grades for assignments. If an array is provided, only assignments whose class ID is in the array will use rubric grades.
* @property {boolean} [genericGradebook] True if DTPS should show the generic gradebook. Ignored if dtpsLMS.gradebook defined.
* @property {string[]} [gradeCalculationAllowlist] An array of class IDs that will use dtpsLMS.calculateGrade, if provided. All other classes will bypass dtpsLMS.calculateGrade and instead use the default LMS grade.
* @property {string[]} [lmsGradebookAllowlist] An array of class IDs that will use dtpsLMS.gradebook, if provided. All other classes will bypass dtpsLMS.gradebook and instead use the generic gradebook if enabled.
*/
/**
* @name dtpsLMS.fetchUser
* @description [REQUIRED] Fetches data for the current user from the LMS. If the user is not signed in, reject with an object that looks like {action: "login", redirectURL: "..."} to login the user.
* @kind function
* @return {Promise<User>} A promise which resolves to a User object
*/
/**
* @name dtpsLMS.fetchUnreadMessageCount
* @description [OPTIONAL] Fetches the unread message count for the current user
* @kind function
* @return {Promise<string>} A promise which resolves to a string depicting the count of unread messages
*/
/**
* @name dtpsLMS.fetchClasses
* @description [REQUIRED] Fetches class data from the LMS
* @kind function
* @param {string} userID The user ID to fetch classes for
* @return {Promise<Class[]>} A promise which resolves to an array of Class objects
*/
/**
* @name dtpsLMS.fetchAssignments
* @description [REQUIRED] Fetches assignment data for a course from the LMS
* @kind function
* @param {string} userID The user ID to fetch assignments for
* @param {string} classID The class ID to fetch assignments for
* @return {Promise<Assignment[]>} A promise which resolves to an array of Assignment objects
*/
/**
* @name dtpsLMS.fetchModules
* @description [OPTIONAL] Fetches module data for a course from the LMS
* @kind function
* @param {string} userID The user ID to fetch modules for
* @param {string} classID The class ID to fetch modules for
* @return {Promise<Module[]>} A promise which resolves to an array of Module objects
*/
/**
* @name dtpsLMS.collapseModule
* @description [OPTIONAL] Collapses a module in the LMS
* @kind function
* @param {string} classID The ID of the class
* @param {string} moduleID The ID of the module to collapse
* @param {boolean} collapsed True if the module is collapsed, false otherwise
* @return {Promise} A promise which resolves when the operation is completed
*/
/**
* @name dtpsLMS.collapseAllModules
* @description [OPTIONAL] Collapses all modules in the LMS
* @kind function
* @param {string} classID The ID of the class
* @param {boolean} collapsed True if all modules should be collapsed, false otherwise
* @return {Promise} A promise which resolves when the operation is completed
*/
/**
* @name dtpsLMS.fetchAnnouncements
* @description [OPTIONAL] Fetches recent announcements for a course from the LMS
* @kind function
* @param {string} classID The class ID to fetch announcements for
* @return {Promise<Announcement[]>} A promise which resolves to an array of Announcement objects
*/
/**
* @name dtpsLMS.fetchMeetingURL
* @description [OPTIONAL] Fetches the videoMeetingURL for a class
* @kind function
* @param {string} classID The class ID to get the videoMeetingURL for
* @return {Promise<string>} A promise which resolves to the videoMeetingURL for the class, or null if the class does not have a videoMeetingURL
*/
/**
* @name dtpsLMS.fetchHomepage
* @description [OPTIONAL] Fetches homepage HTML for a course from the LMS
* @kind function
* @param {string} classID The class ID to get the homepage for
* @return {Promise<string>} A promise which resolves to the HTML for the class homepage
*/
/**
* @name dtpsLMS.fetchUsers
* @description [OPTIONAL] Fetches the users for a course from the LMS
* @kind function
* @param {string} classID The class ID to fetch users for
* @return {Promise<ClassSection[]>} A promise which resolves to an array of sections in this class
*/
/**
* @name dtpsLMS.fetchDiscussionThreads
* @description [OPTIONAL] Fetches discussion threads for a course from the LMS
* @kind function
* @param {string} classID The class ID to fetch discussion threads for
* @return {Promise<PartialDiscussionThread[]>} A promise which resolves to an array of partial Discussion Thread objects
*/
/**
* @name dtpsLMS.fetchDiscussionPosts
* @description [REQUIRED IF dtpsLMS.fetchDiscussionThreads IS IMPLEMENTED] Fetches discussion posts in a thread from the LMS
* @kind function
* @param {string} classID The class ID to fetch discussion posts for
* @param {string} threadID The discussion thread ID to fetch discussion posts for
* @return {Promise<DiscussionThread>} A promise which resolves to a full discussion thread object
*/
/**
* @name dtpsLMS.fetchPages
* @description [OPTIONAL] Fetches pages for a course from the LMS
* @kind function
* @param {string} classID The class ID to fetch pages for
* @return {Promise<PartialPage[]>} A promise which resolves to an array of partial page objects
*/
/**
* @name dtpsLMS.fetchPageContent
* @description [REQUIRED IF dtpsLMS.fetchPages IS IMPLEMENTED] Fetches content for a page from the LMS
* @kind function
* @param {string} classID The class ID to fetch page content for
* @param {string} pageID The page ID to fetch page content for
* @return {Promise<Page>} A promise which resolves to a full DTPS page object
*/
/**
* @name dtpsLMS.gradebook
* @description [OPTIONAL] Renders custom gradebook HTML for unique grading systems or for a more tailored experience. The gradebook only shows for classes with a grade and with at least 1 assignment.
* @kind function
* @param {Class} course Class to render the gradebook for. If custom grade calculation is enabled (dtpsLMS.calculateGrade), those results can be accessed at course.gradeCalculation.
* @return {Promise<string>} A promise which resolves to HTML to render for the class gradebook
*/
/**
* @name dtpsLMS.gradebookDidRender
* @description [OPTIONAL] Called after the HTML returned by dtpsLMS.gradebook has rendered. Runs any initialization needed for the gradebook, such as enabling interactive elements or what-if grades.
* @kind function
* @param {Class} course Class the gradebook was rendered for
*/
/**
* @name dtpsLMS.calculateGrade
* @description [OPTIONAL] Calculates class grades with a custom grade calculation formula. Used for unique grading systems.
* @kind function
* @param {Class} course Class to calculate grades for [DO NOT USE COURSE.ASSIGNMENTS to access assignments for grade calculation. Use the assignments parameter instead.]
* @param {Assignment[]} assignments Assignments used for grade calculation. Use this instead of course.assignments for hypothetical/what-if grade calculation.
* @return {undefined|object} The letter grade should be returned in the "letter" property as a string and the percentage in the "grade" property as a number. Other custom properties can be set if they need to be accessed by dtpsLMS.gradebook. Return undefined if there is no grade for the class.
*/
/**
* @name dtpsLMS.isUsualDueDate
* @description [OPTIONAL, INSTITUTION ONLY] This function returns true if the due date provided is usual/expected and false if the due date is unusual/expected. If the due date is unusual, it is shown in bold in the UI. For institutions where there is a pattern/standard for due dates between classes.
* @kind function
* @param {Date} date The due date to check
* @return {boolean} True if the due date is usual, false if the due date is unusual/unexpected.
*/
/**
* @name dtpsLMS.updateAssignments
* @description [OPTIONAL, INSTITUTION ONLY] This function can be implemented by institution-specific scripts to loop through and override assignment data returned by the LMS.
* @kind function
* @param {Assignment[]} assignments Original assignments array
* @param {Class} course The class that the assignment is in
* @return {Promise<Assignment[]>} A promise that resolves to the updated assignments array
*/
/**
* @name dtpsLMS.updateClasses
* @description [OPTIONAL, INSTITUTION ONLY] This function can be implemented by institution-specific scripts to loop through and override class data returned by the LMS.
* @kind function
* @param {Class[]} classes Original classes array
* @return {Promise<Class[]>} A promise that resolves to the updated classes array
*/
//Type definitions -------------------------------------------------------------------------------------
/**
* @typedef {string|Date} Date
* @description A date string recognized by {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse|Date.parse} or an actual {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date|Date} object
*/
/**
* @typedef {Object} User
* @description Defines user objects in DTPS
* @property {string} name User name
* @property {string} id User ID
* @property {string} photoURL User photo URL
* @property {string} url [ONLY FOR DTPSLMS.FETCHUSERS] The URL to this user's profile. Only used when displaying users in the people tab.
* @property {User[]} [children] [ONLY FOR DTPS.USER] Array of child users. If this is defined, the user is treated as a parent account. Sub-children are not allowed.
* @property {boolean} parent [ONLY FOR DTPS.USER] Automatically managed by DTPS. True if the user is a parent account.
*/
/**
* @typedef {Object} Class
* @description Defines class objects in DTPS
* @property {string} name Name of the class
* @property {string} id Class ID used by Power+
* @property {string} lmsID Class ID used for LMS API calls
* @property {string} userID The ID of the user this class is associated with (from the parameter of dtpsLMS.fetchClasses)
* @property {number} num Index of the class in the dtps.classes array
* @property {string} subject Class subject
* @property {number[]} usualDueRange The range of usual due dates for this course [min,max]. Anything outside this range is considered unusual and highlighted if enabled.
* @property {ClassSection[]|boolean} [people] Users in this class. True if the class supports the "People" tab, but not yet loaded. If this is true dtpsLMS.fetchUsers must be implemented.
* @property {string} [icon] The icon to show with this class
* @property {string} [group] The name of the group that this class is in
* @property {number|string} [period] The period or section the user has this class at
* @property {Date} [startDate] The start date for this course. Only used internally for filtering out stale courses.
* @property {Date} [endDate] The end date for this course. Only used internally for filtering out stale courses.
* @property {Date} [termStartDate] The start date for the term this course is in. Only used internally for filtering out stale courses.
* @property {Date} [termEndDate] The end date for the term this course is in. Only used internally for filtering out stale courses.
* @property {Assignment[]} assignments Class assignments. Assume assignments are still loading if this is undefined. The class has no assignments if this is an empty array. Loaded in dtps.init.
* @property {Module[]|boolean} [modules] Class modules. Assume this class supports the modules feature, but is not yet loaded, if this is true and that the class has no modules if this is an empty array. For LMSs that do not support modules, either keep it undefined or set it to false.
* @property {DiscussionThread[]|boolean} [discussions] Class discussion threads. Assume this class supports discussions, but not yet loaded, if this is true and that the class has no threads if this is an empty array. For LMSs that do not support discussions, either keep it undefined or set it to false.
* @property {Page[]|boolean} [pages] Class pages. Assume this class supports the pages feature, but not yet loaded, if true and that the class has no pages if this is an empty array. For LMSs that do not support pages, either keep it undefined or set it to false.
* @property {string} [newDiscussionThreadURL] A URL the user can visit to create a new discussion thread in this class
* @property {boolean} [homepage] True if the class has a homepage. If a class has a homepage, dtpsLMS.fetchHomepage must be implemented.
* @property {string} [term] Class term
* @property {string} [color] Class color
* @property {number} [grade] Current percentage grade in the class (a number from 0 to 100)
* @property {string} [letter] Current letter grade in the class
* @property {string} [previousLetter] Automatically managed by DTPS. The previous letter grade in this class, based on local grade history.
* @property {string} [image] URL to the class background image
* @property {User} [teacher] Class teacher. If the class has multiple teachers, this is the primary teacher.
* @property {boolean} [hasGradebook] Automatically managed by DTPS. True if the class should show the gradebook tab.
* @property {object} [gradeCalculation] Automatically managed by DTPS. If custom grade calculation is implemented, this will be the results from custom grade calculation returned by dtpsLMS.calculateGrade.
* @property {string} [videoMeetingURL] The URL used to join this class' online meeting. If dtpsLMS.fetchMeetingURL is implemented, DTPS will use it to get the meeting URL if this property is undefined. If this property is null, DTPS will not call dtpsLMS.fetchMeeting URL for the class.
*/