/**
* @file DTPS global search
* @author jottocraft
*
* @copyright Copyright (c) 2018-2023 jottocraft
* @license MIT
*/
/**
* Searches Power+ globally and shows results on the "Search Results" screen
*
* @param term {string} The search term typed in the input box
*/
dtps.globalSearch = function (param) {
var term = param.term;
var type = param.type;
var courseNum = param.course;
$("#dtpsMainSearchBox").attr("data-search-type", param.type).attr("data-dtps-course", param.course).attr("data-ctx-type", param.ctxType).attr("data-ctx-course", param.ctxCourse);
//Ensure classes are rendered in the sidebar
dtps.presentClass("search");
dtps.showClasses();
dtps.selectedContent = "stream";
//Clear active state from all tabs
$("#dtpsTabBar .btn").removeClass("active");
jQuery(".classContent").html([1, 2, 3, 4].map(() => (
/*html*/`
<div class="card assignment graded">
<h4>
<span style="width: 450px;" class="shimmer">Assignment Title</span>
<div class="points shimmer">00/00</div>
</h4>
<h5 style="white-space: nowrap; overflow: hidden;">
<div style="width: 200px;" class="infoChip shimmer"></div>
<i class="fluid-icon statusIcon shimmer">more_horiz</i>
</h5>
</div>
`
)).join(""));
//Fetch everything
var dataset = [];
var datasetPromises = [];
var checkedCourses = [];
(courseNum == "all" ? dtps.classes : [dtps.classes[Number(courseNum)]]).forEach(course => {
if ((type == "assignments") || (type == "coursework") || (type == "everything")) {
datasetPromises.push(new Promise((resolve, reject) => {
if (!course.assignments) return resolve([]);
resolve(course.assignments.map(assignment => ({
title: assignment.title,
class: course.num,
body: $('<div>' + assignment.body + '</div>').text(),
onclick: "dtps.assignment('" + assignment.id + "', " + assignment.class + ")",
locatedIn: assignment.category ? assignment.category : "Assignment",
icon: "assignment",
type: "assignment",
infoIcons: [
assignment.dueAt ? "alarm" : null,
dtps.user.parent ? "person" : null
].filter(i => i),
info: [
assignment.dueAt ? "Due: " + dtps.formatDate(assignment.dueAt) : null,
dtps.user.parent ? dtps.user.children.find(c => c.id == course.userID).name : null
].filter(i => i).join("$|$"),
icons: [
{
icon: assignment.missing ? "remove_circle_outline" : null,
state: "missing"
},
{
icon: assignment.turnedIn ? "assignment_turned_in" : null,
state: "turnedIn"
},
{
icon: assignment.locked ? "lock_outline" : null,
state: "locked"
},
{
icon: assignment.late ? "assignment_late" : null,
state: "late"
}
].filter(i => i.icon)
})));
}));
}
if ((type == "grades") || (type == "everything")) {
datasetPromises.push(new Promise((resolve, reject) => {
if (!course.assignments) return resolve([]);
var res = [];
course.assignments.forEach(assignment => {
if (dtpsLMS.useRubricGrades && assignment.rubric) {
assignment.rubric.forEach(rubric => {
if (rubric.score) {
res.push({
title: assignment.title,
class: course.num,
onclick: "dtps.assignment('" + assignment.id + "', " + assignment.class + ")",
locatedIn: rubric.title,
icon: "assessment",
infoIcons: [
"bar_chart",
dtps.user.parent ? "person" : null
].filter(i => i),
info: [
"Score: " + rubric.score + "/" + rubric.value + (rubric.scoreName ? " " + rubric.scoreName : ""),
dtps.user.parent ? dtps.user.children.find(c => c.id == course.userID).name : null
].filter(i => i).join("$|$"),
icons: []
});
}
});
} else if (assignment.grade) {
res.push({
title: assignment.title,
class: course.num,
onclick: "dtps.assignment('" + assignment.id + "', " + assignment.class + ")",
locatedIn: "Grade",
icon: "assessment",
infoIcons: [
"bar_chart",
dtps.user.parent ? "person" : null
].filter(i => i),
info: [
"Score: " + assignment.grade + "/" + assignment.value + " (" + Math.round((assignment.grade / assignment.value) * 100) + "%" + (assignment.letter ? ", " + assignment.letter : "") + ")",
dtps.user.parent ? dtps.user.children.find(c => c.id == course.userID).name : null
].filter(i => i).join("$|$"),
icons: []
});
}
});
resolve(res);
}));
}
//Check if this course has already been checked
if (checkedCourses.includes(course.lmsID)) return;
checkedCourses.push(course.lmsID);
if ((type == "modules") || (type == "coursework") || (type == "everything")) {
datasetPromises.push(new Promise((resolve, reject) => {
if (!course.modules) return resolve([]);
new Promise(resolve => {
if (course.modules && (course.modules !== true)) {
resolve(course.modules);
} else {
dtpsLMS.fetchModules(dtps.user.id, course.lmsID).then(data => resolve(data));
}
}).then(data => {
var res = [];
data.forEach(module => {
module.items.forEach(item => {
//Prevent assignments from showing twice in coursework search results
//(since assignments can appear in both assignments and modules tab)
if ((item.type == "assignment") && (type == "coursework")) return;
item.locatedIn = module.title;
res.push(item);
});
});
resolve(res.map(item => ({
title: item.title,
class: course.num,
url: item.url,
locatedIn: item.locatedIn,
icon: "view_module",
infoIcons: [
"category"
].filter(i => i),
info: [
"Type: " + item.type
].filter(i => i).join("$|$"),
icons: [
{
icon: item.completed ? "check" : null,
keywords: "completed done submitted"
}
].filter(i => i.icon)
})));
}).catch(() => resolve([]));
}));
}
if ((type == "homepages") || (type == "everything")) {
datasetPromises.push(new Promise((resolve, reject) => {
if (!course.homepage) return resolve([]);
dtpsLMS.fetchHomepage(course.lmsID).then(data => {
resolve([{
title: course.subject + " Homepage",
class: course.num,
onclick: "dtps.classHome(" + course.num + ");",
locatedIn: course.subject,
icon: "home",
body: $('<div>' + data + '</div>').text(),
icons: []
}]);
}).catch(() => resolve([]));
}));
}
if ((type == "pages") || (type == "everything")) {
datasetPromises.push(new Promise((resolve, reject) => {
if (!course.pages) return resolve([]);
dtpsLMS.fetchPages(course.lmsID).then(pages => {
Promise.all(pages.map(page => (
dtpsLMS.fetchPageContent(course.lmsID, page.id)
))).then(pages => {
resolve(pages.map(page => ({
title: page.title,
class: course.num,
onclick: "fluid.screen('pages', '" + course.id + "|" + page.id + "');",
body: $('<div>' + page.content + '</div>').text(),
locatedIn: "Page",
icon: "insert_drive_file",
infoIcons: [
page.author && page.author.name ? "person" : null
].filter(i => i),
info: [
page.author && page.author.name ? "Author: " + page.author && page.author.name : null
].filter(i => i).join("$|$"),
icons: []
})));
})
}).catch(() => resolve([]));
}));
}
if ((type == "discussions") || (type == "everything")) {
datasetPromises.push(new Promise((resolve, reject) => {
if (!course.discussions) return resolve([]);
dtpsLMS.fetchDiscussionThreads(course.lmsID).then(discussions => {
Promise.all(discussions.map(discussion => (
dtpsLMS.fetchDiscussionPosts(course.lmsID, discussion.id)
))).then(discussions => {
var res = [];
discussions.forEach(discussion => {
discussion.posts.forEach(post => {
post.locatedIn = discussion.title;
post.threadID = discussion.id;
res.push(post);
if (post.replies) {
post.replies.forEach(reply => {
reply.locatedIn = discussion.title;
reply.threadID = discussion.id;
res.push(reply);
});
}
})
});
resolve(res.map(post => ({
body: $('<div>' + post.body + '</div>').text(),
class: course.num,
onclick: "fluid.screen('discussions', '" + course.id + "|" + post.threadID + "|false|" + post.id + "');",
locatedIn: post.locatedIn,
icon: "forum",
infoIcons: [
post.author && post.author.name ? "person" : null,
post.postedAt ? "calendar_today" : null
].filter(i => i),
info: [
post.author && post.author.name ? "Author: " + post.author && post.author.name : null,
post.postedAt ? "Posted At: " + dtps.formatDate(post.postedAt) : null
].filter(i => i).join("$|$"),
icons: []
})));
})
}).catch(() => resolve([]));
}));
}
if ((type == "people") || (type == "everything")) {
datasetPromises.push(new Promise((resolve, reject) => {
if (!course.people) return resolve([]);
new Promise(resolve => {
if (course.people && (course.people !== true)) {
resolve(course.people);
} else {
dtpsLMS.fetchUsers(course.lmsID).then(data => resolve(data));
}
}).then(data => {
var res = [];
data.forEach(section => {
section.users.forEach(user => {
user.locatedIn = section.title;
res.push(user);
});
});
resolve(res.map(person => ({
title: person.name,
class: course.num,
url: person.url,
locatedIn: person.locatedIn,
icon: "people",
icons: []
})));
}).catch(() => resolve([]));
}));
}
if ((type == "announcements") || (type == "everything")) {
datasetPromises.push(new Promise((resolve, reject) => {
dtpsLMS.fetchAnnouncements(course.lmsID).then(announcements => {
resolve(announcements.map((announcement) => ({
title: announcement.title,
url: announcement.url,
class: course.num,
locatedIn: course.subject,
icon: "announcement",
body: $('<div>' + announcement.body + '</div>').text(),
infoIcons: [
"calendar_today"
].filter(i => i),
info: [
"Posted at: " + dtps.formatDate(announcement.postedAt)
].filter(i => i).join("$|$"),
icons: []
})));
}).catch(() => resolve([]));
}));
}
});
Promise.all(datasetPromises).then(results => {
results.forEach(result => {
dataset = dataset.concat(result);
});
var idx = lunr(function () {
this.ref('id');
this.field('title');
this.field('body');
this.field('locatedIn');
this.field('info');
this.metadataWhitelist = ['position'];
dataset.forEach(function (doc, i) {
this.add({
id: i,
...doc
});
}, this)
});
var result = idx.search(term);
var resHTML = result.map(res => dtps.renderSearchResult(dataset[Number(res.ref)], res.matchData.metadata, courseNum == "all")).join("");
if (dtps.selectedClass == "search") {
$(".headerArea .contentLabel i").text("find_in_page");
$(".headerArea .contentLabel span").text(result.length + " results");
$(".headerArea .contentLabel").show();
$(".classContent").html(/*html*/`
<div ${!["assignments", "coursework"].includes(type) ? `style="display: none;"` : ``} id="searchFilterContainer">
<div id="searchFilterCard" class="card">
<h5><i class="fluid-icon">filter_alt</i><span>Filters</span></h5>
<div class="checkContainer">
<div id="missingSearchFilter" init="true" onclick="dtps.filterSearch(this)" class="checkbox"><i class="fluid-icon">check</i></div>
<div class="label"><i class="fluid-icon">remove_circle_outline</i> Missing</div>
</div>
<div class="checkContainer">
<div id="turnedInSearchFilter" init="true" onclick="dtps.filterSearch(this)" class="checkbox"><i class="fluid-icon">check</i></div>
<div class="label"><i class="fluid-icon">assignment_turned_in</i> Turned in</div>
</div>
<div class="checkContainer">
<div id="lateSearchFilter" init="true" onclick="dtps.filterSearch(this)" class="checkbox"><i class="fluid-icon">check</i></div>
<div class="label"><i class="fluid-icon">assignment_late</i> Late</div>
</div>
<div class="checkContainer">
<div id="lockedSearchFilter" init="true" onclick="dtps.filterSearch(this)" class="checkbox"><i class="fluid-icon">check</i></div>
<div class="label"><i class="fluid-icon">lock_outline</i> Locked</div>
</div>
</div>
</div>
<div ${["assignments", "coursework"].includes(type) ? `class="withFilters"` : ``} id="searchResultsContainer">
${resHTML}
</div>
`);
if (!dtps.searchScrollListener) {
dtps.searchScrollListener = true;
var searchFilters = document.getElementById("searchFilterCard");
var sticky = searchFilters.offsetTop - parseFloat($("body").css("padding-top"));
window.onscroll = function () {
if ((window.pageYOffset >= sticky) && (dtps.selectedClass == "search")) {
$(".classContent").addClass("fixedSearchFilters");
} else {
$(".classContent").removeClass("fixedSearchFilters");
}
};
}
}
/**
* Filters search results
*
* @param {Element|string} [toggleItem] The selector for the element to toggle
*/
dtps.filterSearch = function (toggleItem) {
if (toggleItem) $(toggleItem).toggleClass("active");
//Check for enabled filters
var missingFilter = $("#missingSearchFilter").hasClass("active");
var turnedInFilter = $("#turnedInSearchFilter").hasClass("active");
var lateFilter = $("#lateSearchFilter").hasClass("active");
var lockedFilter = $("#lockedSearchFilter").hasClass("active");
var filtersActive = missingFilter || turnedInFilter || lateFilter || lockedFilter;
var filterRes = result.filter(function (res) {
var item = dataset[Number(res.ref)];
if (!filtersActive) return true;
if (item.type == "assignment") {
if (missingFilter && item.icons.map(i => i.state).includes("missing")) return true;
if (turnedInFilter && item.icons.map(i => i.state).includes("turnedIn")) return true;
if (lateFilter && item.icons.map(i => i.state).includes("late")) return true;
if (lockedFilter && item.icons.map(i => i.state).includes("locked")) return true;
}
return false;
});
var filterHTML = filterRes.map(res => dtps.renderSearchResult(dataset[Number(res.ref)], res.matchData.metadata, courseNum == "all")).join("");
if (dtps.selectedClass == "search") {
$(".headerArea .contentLabel i").text(filtersActive ? "filter_alt" : "find_in_page");
$(".headerArea .contentLabel span").text(filterRes.length + " results");
$(".headerArea .contentLabel").show();
$("#searchResultsContainer").html(filterHTML);
}
}
});
}
/**
* Renders a Search data object into HTML for display in the search results list
*
* @param {SearchData} result The search result to render
* @param {Object[]} matchData The list of matches from Lunr
* @param {boolean} [mixedClasses] True if there are results from multiple classes
* @return {string} The HTML for this result in the results list
*/
dtps.renderSearchResult = function (result, matchData, mixedClasses) {
if (!result.processed) {
var matches = {};
var matchTerms = Object.values(matchData);
matchTerms.forEach(term => {
Object.keys(term).forEach(k => {
if (matches[k]) {
matches[k] = matches[k].concat(term[k].position);
} else {
matches[k] = term[k].position;
}
});
});
matches = Object.keys(matches).map(k => ({ key: k, position: matches[k] }));
//A function that highlights a string
function highlightString(string, position) {
var parts = string.split("");
position.forEach(pos => {
//highlight part
parts.forEach((part, i) => {
if (i == pos[0]) {
parts[i] = '<span class="highlight">' + parts[i];
}
if (i == (pos[0] + pos[1] - 1)) {
parts[i] = parts[i] + '</span>';
}
});
});
return parts.join("");
}
//Highlight matches
result.bodyParts = [];
result.bodyOverflow = 0;
matches.forEach(match => {
if (match.key == "title") {
result.title = highlightString(result.title, match.position);
} else if (match.key == "locatedIn") {
result.locatedIn = highlightString(result.locatedIn, match.position);
} else if (match.key == "body") {
//Get longest 2 matches
var sections = match.position.sort((a, b) => b[1] - a[1]);
var matchesToDisplay = 2;
if (sections.length < matchesToDisplay) matchesToDisplay = sections.length;
result.bodyOverflow = sections.length - matchesToDisplay;
for (var i = 0; i < matchesToDisplay; i++) {
var matchStartIndex = sections[i][0];
var matchEndIndex = sections[i][0] + sections[i][1] - 1;
var startIndex = matchStartIndex - 10;
var endIndex = matchEndIndex + 10;
if (startIndex < 0) startIndex = 0;
if (endIndex > (result.body.length - 1)) endIndex = result.body.length - 1;
var res = result.body.slice(startIndex, matchStartIndex) + '<span class="highlight">' + result.body.slice(matchStartIndex, matchEndIndex + 1) + '</span>' + result.body.slice(matchEndIndex + 1, endIndex + 1);
result.bodyParts.push(res);
}
} else if (match.key == "info") {
result.info = highlightString(result.info, match.position);
}
});
//Map result info back into an array
if (result.info) {
result.info = result.info.split("$|$").map((info, i) => {
return {
icon: result.infoIcons[i],
info: info,
}
});
}
//Mark this result as processed so it doesn't have to be re-highlighted if a filter is used
result.processed = true;
}
return /*html*/`
<div
class="card searchResult ${mixedClasses ? "mixedClasses" : ""}"
style="${'--classColor: ' + dtps.classes[result.class].color}"
onclick="${result.onclick || (result.url && "window.open('" + result.url + "')")}"
>
<!-- Color bar for the dashboard -->
<div class="colorBar"></div>
<!-- Assignment title and points -->
${result.title ? `<h4>${result.title}</h4>` : ""}
<h5>
<div class="infoChip"><i style="margin-top: -2px;" class="fluid-icon">${result.icon}</i> ${result.locatedIn}</div>
${result.info ? result.info.map(info => (
`<div class="infoChip"><i style="margin-top: -2px;" class="fluid-icon">${info.icon}</i> ${info.info}</div>`
)).join("") : ""}
</h5>
${result.bodyParts.map(part => (
`<p><span style="color: var(--secText);">...</span>${part}<span style="color: var(--secText);">...</span></p>`
)).join("")}
${result.bodyOverflow ? `<p style="color: var(--secText);">+${result.bodyOverflow} more matches</p>` : ""}
<h5>
${result.icons.map((icon, i) => (`<i class="fluid-icon statusIcon">${icon.icon}</i>`)).join("")}
</h5>
</div>
`;
}
//Fluid UI screen definitions
fluid.externalScreens.search = (term) => {
dtps.globalSearch(term);
}
/**
* @typedef {Object} SearchData
* @description An object that can represent any type of data to be searched.
* @property {string} [title] Result title
* @property {string} [body] Result body (can be HTML)
* @property {string} [url] A URL for opening this result in the LMS
* @property {string} locatedIn Describes where this result was found (as specific as possible)
* @property {string} icon The icon to use for this result. Should match (and is displayed with) the locatedIn property.
* @property {Object[]} [info] Contains any info (e.g. due dates) for this result. Has an icon property, label property, and info property.
* @property {Object[]} icons Status icons for this result. Has an "icon" property, for the icon, and a "keywords" property for a string with all of the search keywords for the icon.
* @property {number} class The number of the class that this result is from
*/