mirror of
https://github.com/GuySandler/CanvasRefined.git
synced 2026-11-03 22:07:18 +01:00
global search improvements with @coursename and homepages added
This commit is contained in:
parent
b2c50065fb
commit
79cf43852c
@ -354,7 +354,8 @@
|
|||||||
.canvasrefined-gs-type-module { background: #efe7fb; color: #5b2a9e; border-color: #d8c6f1; }
|
.canvasrefined-gs-type-module { background: #efe7fb; color: #5b2a9e; border-color: #d8c6f1; }
|
||||||
.canvasrefined-gs-type-page { background: #f1f3f5; color: #444; border-color: #dde1e3; }
|
.canvasrefined-gs-type-page { background: #f1f3f5; color: #444; border-color: #dde1e3; }
|
||||||
.canvasrefined-gs-type-file { background: #f1f3f5; color: #444; border-color: #dde1e3; }
|
.canvasrefined-gs-type-file { background: #f1f3f5; color: #444; border-color: #dde1e3; }
|
||||||
.canvasrefined-gs-type-link, .canvasrefined-gs-type-external-tool { background: #fff4d6; color: #7a5b00; border-color: #f1dfab; }
|
.canvasrefined-gs-type-link, .canvasrefined-gs-type-external-tool { background: #fff4d6; color: #7a5b00; border-color: #f1dfab; }
|
||||||
|
.canvasrefined-gs-type-course { background: #dff3f3; color: #0b6e6e; border-color: #b3dede; }
|
||||||
.canvasrefined-gs-footer {
|
.canvasrefined-gs-footer {
|
||||||
display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 14px; font-size: 12px;
|
display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 14px; font-size: 12px;
|
||||||
color: var(--bctext-2, #9aa3ab); border-top: 1px solid var(--bcborders, #e2e6e8);
|
color: var(--bctext-2, #9aa3ab); border-top: 1px solid var(--bcborders, #e2e6e8);
|
||||||
|
|||||||
160
js/content.js
160
js/content.js
@ -6796,7 +6796,7 @@ function openGlobalSearchModal() {
|
|||||||
<div class="canvasrefined-gs-card" role="dialog" aria-modal="true" aria-label="Search Canvas">
|
<div class="canvasrefined-gs-card" role="dialog" aria-modal="true" aria-label="Search Canvas">
|
||||||
<div class="canvasrefined-gs-input-row">
|
<div class="canvasrefined-gs-input-row">
|
||||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="20" height="20" class="canvasrefined-gs-input-icon"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/><path d="m20 20-3.2-3.2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="20" height="20" class="canvasrefined-gs-input-icon"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/><path d="m20 20-3.2-3.2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
<input id="canvasrefined-gs-input" class="canvasrefined-gs-input" type="text" placeholder="Search modules & assignments\u2026" autocomplete="off" spellcheck="false" />
|
<input id="canvasrefined-gs-input" class="canvasrefined-gs-input" type="text" placeholder="Search modules & assignments\u2026 (@coursename filters by course)" autocomplete="off" spellcheck="false" />
|
||||||
<button id="canvasrefined-gs-close" class="canvasrefined-gs-close" type="button" title="Close (Esc)">Esc</button>
|
<button id="canvasrefined-gs-close" class="canvasrefined-gs-close" type="button" title="Close (Esc)">Esc</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="canvasrefined-gs-results" class="canvasrefined-gs-results"></div>
|
<div id="canvasrefined-gs-results" class="canvasrefined-gs-results"></div>
|
||||||
@ -6856,7 +6856,7 @@ function openGlobalSearchModal() {
|
|||||||
// Kick off indexing immediately so the first keystroke is fast.
|
// Kick off indexing immediately so the first keystroke is fast.
|
||||||
ensureGlobalSearchIndex();
|
ensureGlobalSearchIndex();
|
||||||
// Render an initial hint.
|
// Render an initial hint.
|
||||||
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">Start typing to search your modules and assignments.</div>`;
|
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">Start typing to search your modules and assignments. Use @coursename to search a specific course.</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeGlobalSearchModal() {
|
function closeGlobalSearchModal() {
|
||||||
@ -6983,6 +6983,21 @@ async function buildGlobalSearchIndex() {
|
|||||||
const courseName = course.name;
|
const courseName = course.name;
|
||||||
const courseCode = course.course_code || courseName;
|
const courseCode = course.course_code || courseName;
|
||||||
|
|
||||||
|
// The course itself, so class-name queries surface its homepage and
|
||||||
|
// the @course filter has something to match against.
|
||||||
|
const courseKey = `course:${courseId}`;
|
||||||
|
if (!seenContent.has(courseKey)) {
|
||||||
|
seenContent.add(courseKey);
|
||||||
|
index.push({
|
||||||
|
type: "Course",
|
||||||
|
title: courseName,
|
||||||
|
course: courseName,
|
||||||
|
courseCode,
|
||||||
|
courseId,
|
||||||
|
url: `${domain}/courses/${courseId}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Assignments first so their direct URLs win over the module-item
|
// Assignments first so their direct URLs win over the module-item
|
||||||
// versions of the same assignment.
|
// versions of the same assignment.
|
||||||
try {
|
try {
|
||||||
@ -7107,51 +7122,54 @@ function prettyModuleItemType(type) {
|
|||||||
|
|
||||||
// --- Searching --------------------------------------------------------------
|
// --- Searching --------------------------------------------------------------
|
||||||
|
|
||||||
async function runGlobalSearch(query, resultsEl) {
|
// Course-name matching used by the @course filter (case-insensitive, matches
|
||||||
if (!globalSearchIndex && globalSearchIndexPromise) {
|
// anywhere in the full name or the course code, e.g. "101" or "calc"). An
|
||||||
resultsEl.innerHTML = `<div class="canvasrefined-gs-loading">Building search index\u2026</div>`;
|
// empty filter (a bare "@") matches every course.
|
||||||
}
|
function globalSearchCourseMatches(courseEntry, filter) {
|
||||||
const index = await ensureGlobalSearchIndex();
|
const f = (filter || "").toLowerCase();
|
||||||
if (!query) {
|
if (!f) return true;
|
||||||
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">Start typing to search your modules and assignments.</div>`;
|
return (courseEntry.title || "").toLowerCase().includes(f) ||
|
||||||
return [];
|
(courseEntry.courseCode || "").toLowerCase().includes(f);
|
||||||
}
|
}
|
||||||
if (!index || !index.length) {
|
|
||||||
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">No modules or assignments found. Open the search again later if your courses are still loading.</div>`;
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const q = query.toLowerCase();
|
// Splits "@Math 101 homework 5" into { courseFilter, content }. The filter is
|
||||||
const matches = [];
|
// the longest prefix after the last "@" that still matches at least one
|
||||||
for (const item of index) {
|
// course, so course names with spaces work: whatever remains becomes the
|
||||||
// Re-check hidden status at search time so a card hidden after the index
|
// content query. Returns null when there is no @token at all (plain search).
|
||||||
// was cached (10-min TTL) never surfaces in results.
|
function parseGlobalSearchCourseFilter(query, courseEntries) {
|
||||||
if (isCourseHidden(item.courseId)) continue;
|
const at = query.lastIndexOf("@");
|
||||||
const t = (item.title || "").toLowerCase();
|
if (at < 0) return null;
|
||||||
const c = (item.course || "").toLowerCase();
|
// '@' must start the query or follow whitespace, so email addresses and
|
||||||
let score = -1;
|
// URLs the user pastes in aren't split apart.
|
||||||
if (t.startsWith(q)) score = 100 - t.indexOf(q);
|
if (at > 0 && !/\s/.test(query[at - 1])) return null;
|
||||||
else if (t.includes(q)) score = 60 - t.indexOf(q);
|
const before = query.slice(0, at).trim();
|
||||||
else if (c.includes(q)) score = 20;
|
const rest = query.slice(at + 1);
|
||||||
if (score >= 0) { item._score = score + (t === q ? 50 : 0); matches.push(item); }
|
// Longest matching prefix; must end at a word boundary (or the end of the
|
||||||
|
// query) so "@mathh" doesn't silently become course "math" + query "h".
|
||||||
|
for (let len = rest.length; len > 0; len--) {
|
||||||
|
if (len < rest.length && rest[len] !== " ") continue;
|
||||||
|
const candidate = rest.slice(0, len).trim().toLowerCase();
|
||||||
|
if (!candidate) continue;
|
||||||
|
if (courseEntries.some(c => globalSearchCourseMatches(c, candidate))) {
|
||||||
|
return { courseFilter: candidate, content: `${before} ${rest.slice(len)}`.trim() };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
matches.sort((a, b) => b._score - a._score);
|
if (!rest.trim()) return { courseFilter: "", content: before }; // bare "@" -> all courses
|
||||||
const top = matches.slice(0, 50);
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!top.length) {
|
function globalSearchRowHtml(item, i) {
|
||||||
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">No results for \u201c${escapeGlobalSearchHtml(query)}\u201d.</div>`;
|
return `
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
resultsEl.innerHTML = top.map((item, i) => `
|
|
||||||
<div class="canvasrefined-gs-row" data-i="${i}" data-url="${escapeGlobalSearchAttr(item.url)}">
|
<div class="canvasrefined-gs-row" data-i="${i}" data-url="${escapeGlobalSearchAttr(item.url)}">
|
||||||
<div class="canvasrefined-gs-row-main">
|
<div class="canvasrefined-gs-row-main">
|
||||||
<span class="canvasrefined-gs-type canvasrefined-gs-type-${escapeGlobalSearchAttr((item.type || "").toLowerCase().replace(/\s+/g, "-"))}">${escapeGlobalSearchHtml(item.type || "")}</span>
|
<span class="canvasrefined-gs-type canvasrefined-gs-type-${escapeGlobalSearchAttr((item.type || "").toLowerCase().replace(/\s+/g, "-"))}">${escapeGlobalSearchHtml(item.type || "")}</span>
|
||||||
<span class="canvasrefined-gs-title">${escapeGlobalSearchHtml(item.title || "")}</span>
|
<span class="canvasrefined-gs-title">${escapeGlobalSearchHtml(item.title || "")}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="canvasrefined-gs-course">${escapeGlobalSearchHtml(item.course || "")}</span>
|
<span class="canvasrefined-gs-course">${escapeGlobalSearchHtml(item.course || "")}</span>
|
||||||
</div>`).join("");
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindGlobalSearchRows(resultsEl, top) {
|
||||||
resultsEl.querySelectorAll(".canvasrefined-gs-row").forEach((row) => {
|
resultsEl.querySelectorAll(".canvasrefined-gs-row").forEach((row) => {
|
||||||
// Plain click / Ctrl+click: honor modifier for new-tab behavior.
|
// Plain click / Ctrl+click: honor modifier for new-tab behavior.
|
||||||
row.addEventListener("click", (e) => {
|
row.addEventListener("click", (e) => {
|
||||||
@ -7168,6 +7186,74 @@ async function runGlobalSearch(query, resultsEl) {
|
|||||||
if (item) openGlobalSearchResult(item, true);
|
if (item) openGlobalSearchResult(item, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runGlobalSearch(query, resultsEl) {
|
||||||
|
if (!globalSearchIndex && globalSearchIndexPromise) {
|
||||||
|
resultsEl.innerHTML = `<div class="canvasrefined-gs-loading">Building search index\u2026</div>`;
|
||||||
|
}
|
||||||
|
const index = await ensureGlobalSearchIndex();
|
||||||
|
if (!query) {
|
||||||
|
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">Start typing to search your modules and assignments. Use @coursename to search a specific course.</div>`;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!index || !index.length) {
|
||||||
|
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">No modules or assignments found. Open the search again later if your courses are still loading.</div>`;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// "@course name" narrows the search to one course. With no other words
|
||||||
|
// after the filter (or a bare "@"), list the matching courses' homepages.
|
||||||
|
const courseEntries = index.filter(i => i.type === "Course");
|
||||||
|
const parsed = parseGlobalSearchCourseFilter(query, courseEntries);
|
||||||
|
let effectiveQuery = query;
|
||||||
|
let allowedCourseIds = null;
|
||||||
|
if (parsed) {
|
||||||
|
effectiveQuery = parsed.content;
|
||||||
|
const matching = courseEntries.filter(c => globalSearchCourseMatches(c, parsed.courseFilter));
|
||||||
|
allowedCourseIds = new Set(matching.map(c => String(c.courseId)));
|
||||||
|
if (!effectiveQuery) {
|
||||||
|
if (!matching.length) {
|
||||||
|
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">No course matches \u201c${escapeGlobalSearchHtml(parsed.courseFilter)}\u201d.</div>`;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
resultsEl.innerHTML = matching.map((item, i) => globalSearchRowHtml(item, i)).join("");
|
||||||
|
bindGlobalSearchRows(resultsEl, matching);
|
||||||
|
return matching;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = effectiveQuery.toLowerCase();
|
||||||
|
const matches = [];
|
||||||
|
for (const item of index) {
|
||||||
|
// Re-check hidden status at search time so a card hidden after the index
|
||||||
|
// was cached (10-min TTL) never surfaces in results.
|
||||||
|
if (isCourseHidden(item.courseId)) continue;
|
||||||
|
// An @course filter restricts results to that course's entries.
|
||||||
|
if (allowedCourseIds && !allowedCourseIds.has(String(item.courseId))) continue;
|
||||||
|
const t = (item.title || "").toLowerCase();
|
||||||
|
const c = (item.course || "").toLowerCase();
|
||||||
|
let score = -1;
|
||||||
|
if (t.startsWith(q)) score = 100 - t.indexOf(q);
|
||||||
|
else if (t.includes(q)) score = 60 - t.indexOf(q);
|
||||||
|
else if (c.includes(q)) score = 20;
|
||||||
|
// Course homepages float above item matches for class-name queries.
|
||||||
|
if (score >= 0) {
|
||||||
|
if (item.type === "Course" && t.startsWith(q)) score += 40;
|
||||||
|
item._score = score + (t === q ? 50 : 0);
|
||||||
|
matches.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches.sort((a, b) => b._score - a._score);
|
||||||
|
const top = matches.slice(0, 50);
|
||||||
|
|
||||||
|
if (!top.length) {
|
||||||
|
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">No results for \u201c${escapeGlobalSearchHtml(query)}\u201d.</div>`;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsEl.innerHTML = top.map((item, i) => globalSearchRowHtml(item, i)).join("");
|
||||||
|
bindGlobalSearchRows(resultsEl, top);
|
||||||
return top;
|
return top;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user