UoM Blackboard: Add course images

An optional accompanying script for https://github.com/adil192/BlackboardTheme, which adds better course images to the Blackboard homepage.

// ==UserScript==
// @name         UoM Blackboard: Add course images
// @namespace    http://tampermonkey.net/
// @version      20240128.00.00
// @description  An optional accompanying script for https://github.com/adil192/BlackboardTheme, which adds better course images to the Blackboard homepage.
// @author       adil192
// @match        https://online.manchester.ac.uk/ultra/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=manchester.ac.uk
// @grant        none
// @license      Unlicense
// ==/UserScript==

// @ts-check

/**
 * A string that is used as a placeholder in the module images database
 * when no Pixabay images are found for a module.
 * 
 * This is used to prevent the script from trying to find images for the
 * same module again and again, using up the Pixabay API quota.
 */
const NORESULTS = "NORESULTS";

/**
 * Known modules whose images aren't from Pixabay.
 * @type {Record<string, string>}
 */
const knownModuleImages = {
    "ARDSE001" : "https://raw.githubusercontent.com/adil192/BlackboardTheme/main/assets/subjects/ARDSE001/ARDSE001.jpg",

    "COMP16321": "https://raw.githubusercontent.com/adil192/BlackboardTheme/main/assets/subjects/COMP16321/COMP16321.jpg",
    "COMP28112": "https://raw.githubusercontent.com/adil192/BlackboardTheme/main/assets/subjects/COMP38311/COMP28112.jpg",
    "COMP30040": "https://raw.githubusercontent.com/adil192/BlackboardTheme/main/assets/subjects/COMP30040/COMP30040.jpg",
    "COMP33312": "https://raw.githubusercontent.com/adil192/BlackboardTheme/main/assets/subjects/COMP33312/COMP33312.jpg",
    "COMP33511": "https://raw.githubusercontent.com/adil192/BlackboardTheme/main/assets/subjects/COMP33511/COMP33511.jpg",
    "COMP36212": "https://raw.githubusercontent.com/adil192/BlackboardTheme/main/assets/subjects/COMP36212/COMP36212.jpg",
    "COMP38311": "https://raw.githubusercontent.com/adil192/BlackboardTheme/main/assets/subjects/COMP38311/COMP38311.jpg",

    "MATH11711": "https://raw.githubusercontent.com/adil192/BlackboardTheme/main/assets/subjects/MATH11711/MATH11711.jpg",
    "MATH31051": "https://raw.githubusercontent.com/adil192/BlackboardTheme/main/assets/subjects/MATH31051/MATH31051.png",
    "MATH32031": "https://raw.githubusercontent.com/adil192/BlackboardTheme/main/assets/subjects/MATH32031/MATH32031.jpg",
    "MATH32052": "https://raw.githubusercontent.com/adil192/BlackboardTheme/main/assets/subjects/MATH32052/MATH32052.jpg",
    "MATH32091": "https://raw.githubusercontent.com/adil192/BlackboardTheme/main/assets/subjects/MATH32091/MATH32091.png",
};

/**
 * A module whose name matches one of the regular expressions in this map
 * will use the corresponding image.
 * 
 * Modules defined in `knownModuleImages` will take priority.
 * 
 * @type {Map<RegExp, string>}
 */
const specialModuleImages = new Map([
    [/team project/i, "https://raw.githubusercontent.com/adil192/BlackboardTheme/main/assets/subjects/team/team.jpg"],
]);


/**
 * The IndexDB database with the cached module images.
 * @type {IDBDatabase | null}
 */
let moduleImagesDB = null;

/**
 * Opens the IndexDB database with the cached module images.
 * @returns {Promise<void>}
 */
function openDb() {
    return new Promise((resolve, reject) => {
        const request = indexedDB.open("moduleImages", 1);
        request.onerror = reject;
        request.onsuccess = () => {
            moduleImagesDB = request.result;
            resolve();
        };
        request.onupgradeneeded = () => {
            const db = request.result;
            db.createObjectStore("moduleImages");
        };
    });
}

/** @type {HTMLDivElement | null} */
let coursesDiv = null;

/**
 * Returns a promise that resolves after `ms` milliseconds.
 * @param {number} ms
 * @returns {Promise<void>}
 */
function timeoutPromise(ms) {
    return new Promise((resolve) => {
        setTimeout(resolve, ms);
    });
}

/**
 * Returns a promise that resolves when `coursesDivs` are found.
 * @returns {Promise<void>}
 */
async function findCoursesDivs() {
    while (!coursesDiv) {
        coursesDiv = document.querySelector('.course-org-list');
        if (!coursesDiv) await timeoutPromise(50);
    }
    console.log('found courses div:', {coursesDiv});
}

/**
 * Returns the module image for the given module name, or null if it doesn't exist.
 * @param {string} moduleName
 * @returns {Promise<string | null>}
 */
async function findModuleImage(moduleName) {
    if (!moduleImagesDB) {
        console.error('moduleImagesDB not open');
        return null;
    }

    for (const moduleCode in knownModuleImages) {
        if (!moduleName.startsWith(moduleCode)) continue;
        return knownModuleImages[moduleCode];
    }

    for (const [regex, image] of specialModuleImages) {
        if (!regex.test(moduleName)) continue;
        return image;
    }

    const transaction = moduleImagesDB.transaction("moduleImages", "readonly");
    const objectStore = transaction.objectStore("moduleImages");
    const cachedImage = await new Promise((resolve) => {
        const request = objectStore.get(moduleName);
        request.onsuccess = () => resolve(request.result);
        request.onerror = () => resolve(null);
    });

    if (cachedImage) {
        if (cachedImage === NORESULTS) return null;
        else return cachedImage;
    } else {
        return await findAndCacheModuleImage(moduleName);
    }
}

/**
 * Finds a module image from Pixabay for the given module name,
 * and caches it in the module images database.
 * 
 * 
 * @param {string} moduleName
 * @returns {Promise<string | null>}
 */
async function findAndCacheModuleImage(moduleName) {
    const apiKey = "25286000-bf7eb8ff8e6d2d1630cf59fae";
    const keywords = extractKeywords(moduleName);
    const url = `https://pixabay.com/api/?key=${apiKey}&q=${encodeURIComponent(keywords)}&image_type=photo&safesearch=true&orientation=horizontal&per_page=3`;
    console.log(`Searching Pixabay for ${moduleName}: ${url}`);
    const response = await fetch(url);
    const json = await response.json();
    const hits = json.hits;
    if (hits.length === 0) {
        console.error(`No Pixabay images found for ${moduleName}`);

        // Don't try again for this module.
        if (moduleImagesDB) {
            const transaction = moduleImagesDB.transaction("moduleImages", "readwrite");
            const objectStore = transaction.objectStore("moduleImages");
            objectStore.put(NORESULTS, moduleName);
        }
        return null;
    }
    const moduleImage = hits[0].webformatURL;
    const dataUrl = await urlToDataUrl(moduleImage);

    if (moduleImagesDB) {
        const transaction = moduleImagesDB.transaction("moduleImages", "readwrite");
        const objectStore = transaction.objectStore("moduleImages");
        objectStore.put(dataUrl, moduleName);
    }
    return dataUrl;
}

/**
 * Converts an image URL to a data URL.
 * @param {string} url
 * @returns {Promise<string>}
 */
function urlToDataUrl(url) {
    return new Promise((resolve) => {
        const canvas = document.createElement("canvas");
        const context = canvas.getContext("2d");
        if (!context) throw new Error("Failed to get canvas context");
        const image = new Image();
        image.addEventListener("load", () => {
            canvas.width = image.width;
            canvas.height = image.height;
            context.drawImage(image, 0, 0);
            const dataUrl = canvas.toDataURL("image/webp");
            resolve(dataUrl);
        });
        image.setAttribute("crossorigin", "anonymous");
        image.src = url;
    });
}

/**
 * Extracts the keywords from a module name.
 *
 * E.g. "COMP30040&30030 Third Year Project Laboratory 2023-24 Full Year"
 * becomes "Project Laboratory".
 * 
 * E.g. "COMP33511 User Experience 2023-24 1st Semester"
 * becomes "User Experience".
 *
 * @param {string} moduleName
 * @returns {string}
 */
function extractKeywords(moduleName) {
    let words = moduleName.split(" ");
    
    // remove any words that contain a number
    words = words.filter((word) => !/\d/.test(word));

    // remove banned words
    const bannedWords = [
        "semester", "full", "year",
        "and", "or", "the", "of", "in", "to", "for", "with", "on", "at", "from",
        "first", "second", "third", "fourth", "fifth",
        "i", "ii", "iii", "iv", "v",
        "-", "‐", "‒", "–", "—", "―", "−",
    ];
    words = words.filter((word) => !bannedWords.includes(word));

    if (words.length > 2) {
        const bannedWords = [
            "introduction", "foundations", "principles", "understanding",
        ];
        words = words.filter((word) => !bannedWords.includes(word));
    }

    // limit to the first 4 words
    words = words.slice(0, 2);

    return words.join(" ");
}

/**
 * Sets the `data-module-code` attribute of each course to the course code.
 */
function labelCourses() {
    if (!coursesDiv) return;
    /** @type {NodeListOf<HTMLLIElement>} */
    const courses = coursesDiv.querySelectorAll(".course-element-card");
    courses.forEach((course) => {
        // don't set the module image if it already exists,
        // stored in `var(--module-image)`
        if (course.style.getPropertyValue("--module-image")) return;

        /** @type {HTMLElement | null} */
        const courseTitleA = course.querySelector(".course-title");
        /** @type {HTMLElement | null} */
        const courseTitleDiv = course.querySelector(".course-banner");

        if (!courseTitleA || !courseTitleDiv) return;

        const moduleName = courseTitleA.textContent?.trim();
        if (!moduleName) return;

        // set to "none" so we don't run findModuleImage() again
        course.style.setProperty("--module-image", "none");
        // set the module code
        findModuleImage(moduleName).then((moduleImage) => {
            if (!moduleImage) return;
            course.style.setProperty("--module-image", `url(${moduleImage})`);
        });
    });
}

(function () {
    'use strict';

    console.log("UoM Blackboard: Add course images");

    Promise.all([
        findCoursesDivs(),
        openDb(),
    ]).then(() => {
        labelCourses();

        coursesDiv?.addEventListener("DOMSubtreeModified", () => {
            labelCourses();
        }, { passive: true });
    });
})();