Udemy - Improved Course Library

Adds current ratings and other detailed data to all courses in your Udemy library.

// ==UserScript==
// @name            Udemy - Improved Course Library
// @name:de         Udemy - Verbesserte Kursbibliothek
// @name:fr         Udemy - Bibliothèque de cours améliorée
// @name:es         Udemy - Biblioteca de cursos mejorada
// @name:it         Udemy - Libreria dei corsi migliorata
// @name:ja         Udemy - コースライブラリの改良
// @description     Adds current ratings and other detailed data to all courses in your Udemy library.
// @description:de  Fügt aktuelle Bewertungen und andere detaillierte Informationen zu allen Kursen in deiner Udemy-Bibliothek hinzu.
// @description:fr  Ajoute les évaluations actuelles et d'autres données détaillées à tous les cours de ta bibliothèque Udemy.
// @description:es  Añade valoraciones actuales y otros datos detallados a todos los cursos de tu biblioteca Udemy.
// @description:it  Aggiunge valutazioni attuali e altri dati dettagliati a tutti i corsi nella tua libreria Udemy.
// @description:ja  Udemyのライブラリにある全てのコースに現在の評価やその他の詳細情報を追加します。
// @namespace       https://github.com/tadwohlrapp
// @author          Tad Wohlrapp
// @version         1.1.2
// @license         MIT
// @homepageURL     https://github.com/tadwohlrapp/udemy-improved-course-library
// @supportURL      https://github.com/tadwohlrapp/udemy-improved-course-library/issues
// @icon            https://github.com/tadwohlrapp/udemy-improved-course-library/raw/main/src/icon48.png
// @icon64          https://github.com/tadwohlrapp/udemy-improved-course-library/raw/main/src/icon64.png
// @run-at          document-end
// @match           https://www.udemy.com/home/my-courses/*
// @compatible      firefox Tested on Firefox v117.0 with Violentmonkey v2.15.0 and Tampermonkey v4.19.0
// @compatible      chrome Tested on Chrome v115.0 with Violentmonkey v2.15.0 and Tampermonkey v4.19.0
// ==/UserScript==

fetchCourses();

const mutationObserver = new MutationObserver(fetchCourses);
const observerConfig = {
  childList: true,
  subtree: true
};
mutationObserver.observe(document, observerConfig);

const i18n = loadTranslations();
const lang = getLang(document.documentElement.lang);

function fetchCourses() {
  listenForArchiveToggle();
  const courseContainers = document.querySelectorAll('[class^="enrolled-course-card--container--"]:not(.details-done)');
  if (courseContainers.length == 0) return;
  [...courseContainers].forEach((courseContainer) => {

    const isPartialRefresh = courseContainer.classList.contains('partial-refresh');

    const courseId = courseContainer.querySelector('h3[data-purpose="course-title-url"]>a').href.replace('https://www.udemy.com/course-dashboard-redirect/?course_id=', '');

    const courseCustomDiv = document.createElement('div');
    courseCustomDiv.classList.add('improved-course-card--additional-details', 'js-removepartial');

    const innerContainer = courseContainer.querySelector('div[data-purpose="container"]')
    innerContainer.appendChild(courseCustomDiv);

    courseContainer.classList.add('details-done');
    courseContainer.classList.add('improved-course-card--container');
    courseContainer.classList.remove('partial-refresh');

    // Add Link to course overview to options dropdown
    const courseLinkLi = document.createElement('li');
    courseLinkLi.innerHTML = `
      <a class="udlite-btn udlite-btn-large udlite-btn-ghost udlite-text-sm udlite-block-list-item udlite-block-list-item-small udlite-block-list-item-neutral" role="menuitem" tabindex="-1" href="https://www.udemy.com/course/${courseId}/" target="_blank" rel="noopener">
        <span class="udi-small udi udi-explore udlite-block-list-item-icon"></span>
        <div class="udlite-block-list-item-content card__course-link">${i18n[lang].overview}
          <svg fill="#686f7a" width="12" height="16" viewBox="0 0 24 24" style="vertical-align: bottom; margin-left: 5px;" xmlns="http://www.w3.org/2000/svg">
            <path d="M19 19H5V5h7V3H5a2 2 0 00-2 2v14c0 1.1.9 2 2 2h14a2 2 0 002-2v-7h-2v7zM14 3v2h3.6l-9.8 9.8 1.4 1.4L19 6.4V10h2V3h-7z"></path>
          </svg>
        </div>
      </a>
    `;
    courseLinkLi.classList.add('js-removepartial');

    const allDropdowns = courseContainer.parentElement.querySelectorAll('.udlite-block-list');
    if (allDropdowns[1]) {
      allDropdowns[1].appendChild(courseLinkLi);
    }

    // Find existing elements in DOM
    const imageWrapper = courseContainer.querySelector('div[class^="course-card-module--image-container--"]');
    imageWrapper.classList.add('improved-course-card--image-container');

    const mainContent = courseContainer.querySelector('div[class^="course-card-module--main-content--"]');
    mainContent.classList.add('improved-course-card--main-content');

    const courseTitle = courseContainer.querySelector('h3[data-purpose="course-title-url"]');
    courseTitle.classList.add('improved-course-card--course-title');

    const priceTextContainer = courseContainer.querySelector('div[class^="course-card-module--price-text-container--"]');
    if (priceTextContainer) priceTextContainer.parentNode.removeChild(priceTextContainer);

    const courseBadges = courseContainer.querySelector('div[class^="course-card-module--badges-container--"]');
    if (courseBadges) courseBadges.parentNode.removeChild(courseBadges);

    const progressBar = courseContainer.querySelector('div[class^="enrolled-course-card--meter--"]');
    progressBar?.classList.add('improved-course-card--meter');

    const progressAndRating = courseContainer.querySelector('div[class*="enrolled-course-card--progress-and-rating--"]');
    progressAndRating?.classList.add('improved-course-card--progress-and-rating');

    const progressText = progressAndRating.firstChild;
    const progressMade = /%/.test(progressText.textContent);

    if (!progressMade) progressAndRating.parentNode.removeChild(progressAndRating);

    // If progress made
    if (progressMade) {
      // Add progress bar below thumbnail
      const progressBarSpan = document.createElement('span');
      progressBarSpan.classList.add('impr__progress-bar', 'js-removepartial');
      progressBarSpan.innerHTML = progressBar.innerHTML;
      imageWrapper.appendChild(progressBarSpan);
      // Add progress percentage to thumbnail bottom right
      const progressTextSpan = document.createElement('span');
      progressTextSpan.classList.add('card__thumb-overlay', 'card__course-runtime', 'hover-show', 'js-removepartial');
      progressTextSpan.textContent = progressText.textContent;
      imageWrapper.appendChild(progressTextSpan);
      // Remove existing progress percentage
      progressText.parentNode.removeChild(progressText);
    }

    // Remove existing progress bar
    if (!isPartialRefresh) {
      progressBar.parentNode.removeChild(progressBar);
    }

    // If course page has draft status, do not even to fetch its data via API
    if (courseContainer.querySelector('[data-purpose="course-title-url"] a').href.includes('/draft/')) {
      courseContainer.querySelector('.card__course-link').style.textDecoration = "line-through";
      courseCustomDiv.classList.add('card__nodata');
      courseCustomDiv.innerHTML += i18n[lang].notavailable;
      // We're done with this course
      return;
    }

    const fetchUrl = 'https://www.udemy.com/api-2.0/courses/' + courseId + '?fields[course]=rating,num_reviews,num_subscribers,content_length_video,last_update_date,created,locale,has_closed_caption,caption_languages,num_published_lectures';
    fetch(fetchUrl)
      .then(response => {
        if (response.ok) {
          return response.json();
        } else {
          throw new Error(response.status);
        }
      })
      .then(json => {
        if (typeof json === 'undefined') { return; }

        // Get everything from JSON and put it in variables
        const rating = json.rating.toFixed(1);
        const reviews = json.num_reviews;
        const enrolled = json.num_subscribers;
        const runtime = json.content_length_video;
        const date = json.last_update_date ?? json.created.slice(0, 10); // 'created' comes as full iso string with time
        const locale = json.locale.title;
        const localeCode = json.locale.locale;
        const hasCaptions = json.has_closed_caption;
        const captionsLangs = json.caption_languages;

        // Format "Last updated / Created" Dates
        const updateDateShort = date ? date.replace(/(\d{4})-(\d{2})-(\d{2})/, '$2\/$1') : '';
        const updateDateLong = date ? new Date(date).toLocaleDateString(lang, { year: 'numeric', month: 'long', day: 'numeric' }) : '';

        // Small helper for rating strip color
        const getColor = v => `hsl(${(Math.round((1 - v) * 120))},100%,45%)`;
        const colorValue = r => Math.min(Math.max((5 - r) / 2, 0), 1);

        // If captions are available, create the tag for it. We'll add it in template string later
        let captionsTag = '';
        if (hasCaptions) {
          const captionsString = captionsLangs.join('&#013;&#010;');
          captionsTag = `
            <div class="impr__badge" data-tooltip="${captionsString}">
              <svg aria-hidden="true" focusable="false" class="ud-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                <path d="M21 4H3v16h18V4zm-10 7H9.5v-.5h-2v3h2V13H11v2H6V9h5v2zm7 0h-1.5v-.5h-2v3h2V13H18v2h-5V9h5v2z"/>
              </svg>
            </div>
          `;
        }

        // Returns true or false depending if stars are visible
        const reviewButton = courseContainer.querySelector('[data-purpose="review-button"]');

        // Now let's handle own ratings

        // Set up empty html
        let myRatingHtml = '';
        let ratingButton;
        let ratingOwn = 0;

        // If ratings stars ARE visible, proceed to build own rating stars
        if (reviewButton != null) {

          // Find the rating-button, and remove its css class
          ratingButton = reviewButton;

          // If I have voted, count the stars and tell me how I voted
          ratingOwn = getRatingFromSvg(ratingButton.querySelector('svg')); // between 0 and 5

          // Remove the old stars from ratingButton
          ratingButton.removeChild(ratingButton.querySelector('span'));

          // Build the html
          myRatingHtml = `
            <div class="impr__rating-row">
              <span class="impr__star-wrapper">
                <span class="ud-sr-only">Rating: ${ratingOwn} out of 5</span>
                ${buildSvgStars(courseId.toString() + '-own', ratingOwn)}
                <span class="ud-heading-sm impr__rating-number">${setDecimal(ratingOwn, lang)}</span>
              </span>
              <span class="ud-text-xs impr__rating-count">(<span class="review-button"></span>)</span>
            </div>
          `;
        }

        const ratingStripColor = ratingOwn > 0 ? ratingOwn : rating;

        let updateDateInfo = '';
        if (updateDateShort !== '' && updateDateLong !== '') {
          updateDateInfo = `
            <div class="impr__badge" data-tooltip="${i18n[lang].updated}${updateDateLong}">
              <svg aria-hidden="true" focusable="false" class="ud-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                <path d="M11 8v5l4.3 2.5.7-1.3-3.5-2V8H11zm10 2V3l-2.6 2.6A9 9 0 1 0 21 12h-2a7 7 0 1 1-2-5l-3 3h7z"/>
              </svg><span>${updateDateShort}</span>
            </div>
          `;
        }

        courseCustomDiv.innerHTML = `
          <div class="impr__rating">
            <div class="impr__rating-row">
              <span class="impr__star-wrapper">
                <span class="ud-sr-only">Rating: ${rating} out of 5</span>
                ${buildSvgStars(courseId, rating)}
                <span class="ud-heading-sm impr__rating-number">${setDecimal(rating, lang)}</span>
              </span>
              <span class="ud-text-xs impr__rating-count">(${setSeparator(reviews, lang)})</span>
            </div>
            ${myRatingHtml}
          </div>
          <div class="impr__rating-strip" style="background-color:${getColor(colorValue(ratingStripColor))}"></div>
          <div class="impr__stats">
            <div class="impr__badge" data-tooltip="${setSeparator(enrolled, lang)} ${i18n[lang].enrolled}">
              <svg aria-hidden="true" focusable="false" class="ud-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                <path d="M16 11c1.7 0 3-1.3 3-3s-1.3-3-3-3-3 1.3-3 3 1.3 3 3 3zm-8 0c1.7 0 3-1.3 3-3S9.7 5 8 5 5 6.3 5 8s1.3 3 3 3zm0 2c-2.3 0-7 1.2-7 3.5V19h14v-2.5c0-2.3-4.7-3.5-7-3.5zm8 0h-1c1.2.9 2 2 2 3.5V19h6v-2.5c0-2.3-4.7-3.5-7-3.5z"/>
              </svg><span>${setSeparator(enrolled, lang)}</span>
            </div>
            ${updateDateInfo}
            ${captionsTag}
          </div>
        `;

        if (reviewButton != null) {
          const reviewButtonContainer = courseCustomDiv.querySelector('.review-button');
          ratingButton.style.display = 'inline';
          reviewButtonContainer.appendChild(ratingButton);
        }

        // Hide language badge if language is English
        if (localeCode.slice(0, 2) !== 'en') {
          const localeSpan = document.createElement('span');
          localeSpan.classList.add('card__thumb-overlay', 'card__course-locale', 'hover-hide', 'js-removepartial');
          localeSpan.innerHTML = `<span style="margin-right: 3px;vertical-align: bottom;font-size: 14px;line-height: 13px;">${getFlagEmoji(localeCode.slice(-2))}</span>${locale}`;
          imageWrapper.appendChild(localeSpan);
        }

        // Add course runtime from API to thumbnail bottom right
        const runtimeSpan = document.createElement('span');
        runtimeSpan.classList.add('card__thumb-overlay', 'card__course-runtime', 'hover-hide', 'js-removepartial');
        runtimeSpan.innerHTML = parseRuntime(runtime, lang);
        imageWrapper.appendChild(runtimeSpan);
      })
      .catch(error => {
        courseCustomDiv.classList.add('card__nodata');
        courseCustomDiv.innerHTML += `<div><b>${error}</b><br>${i18n[lang].notavailable}</div>`;
      });
  });
}

function listenForArchiveToggle() {

  document.querySelectorAll('[data-purpose="toggle-archived"]').forEach(item => {
    item.addEventListener('click', event => {

      // super super dirty quickfix for broken archiving. I am sorry
      setTimeout(() => {
        location.reload();
      }, 500)

    });
  });
}

function setSeparator(int, lang) {
  return int.toString().replace(/\B(?=(\d{3})+(?!\d))/g, i18n[lang].separator);
}

function setDecimal(rating, lang) {
  return rating.toString().replace('.', i18n[lang].decimal);
}

function getLang(lang) {
  return i18n.hasOwnProperty(lang) ? lang : 'en-us';
}

function buildSvgStars(courseId, rating) {
  return (`
<svg aria-hidden="true" viewBox="0 0 70 14" fill="none" xmlns="http://www.w3.org/2000/svg" class="impr__svg-stars">
  <mask id="mask-${courseId}" data-purpose="star-rating-mask">
    <rect x="0" y="0" width="${rating * 20}%" height="100%" fill="white"></rect>
  </mask>
  <g fill="#e59819" mask="url(#mask-${courseId})" data-purpose="star-filled">
    <use xlink:href="#icon-rating-star" width="14" height="14" x="0"></use>
    <use xlink:href="#icon-rating-star" width="14" height="14" x="14"></use>
    <use xlink:href="#icon-rating-star" width="14" height="14" x="28"></use>
    <use xlink:href="#icon-rating-star" width="14" height="14" x="42"></use>
    <use xlink:href="#icon-rating-star" width="14" height="14" x="56"></use>
  </g>
  <g fill="transparent" stroke="#e59819" stroke-width="2" data-purpose="star-bordered">
    <use xlink:href="#icon-rating-star" width="12" height="12" x="1" y="1"></use>
    <use xlink:href="#icon-rating-star" width="12" height="12" x="15" y="1"></use>
    <use xlink:href="#icon-rating-star" width="12" height="12" x="29" y="1"></use>
    <use xlink:href="#icon-rating-star" width="12" height="12" x="43" y="1"></use>
    <use xlink:href="#icon-rating-star" width="12" height="12" x="57" y="1"></use>
  </g>
</svg>
  `);
}

function parseRuntime(seconds, lang) {
  if (seconds % 60 > 29) { seconds += 30; }
  let hours = Math.floor(seconds / 60 / 60);
  let minutes = Math.floor(seconds / 60) - (hours * 60);
  let hoursFormatted = hours > 0 ? hours.toString() + i18n[lang].hours : '';
  let minutesFormatted = minutes > 0 ? ' ' + minutes.toString() + i18n[lang].mins : '';
  return hoursFormatted + minutesFormatted;
}

function getRatingFromSvg(svgElement) {
  let percentage = svgElement.querySelector('mask rect').getAttribute('width');
  let rating = parseFloat(percentage) / 100 * 5;
  return rating;
}

function loadTranslations() {
  return {
    'en-us': {
      'overview': 'Course overview',
      'enrolled': 'students',
      'updated': 'Last updated ',
      'notavailable': 'Course info not available',
      'separator': ',',
      'decimal': '.',
      'hours': 'h',
      'mins': 'm'
    },
    'de-de': {
      'overview': 'Kursübersicht',
      'enrolled': 'Teilnehmer',
      'updated': 'Zuletzt aktualisiert ',
      'notavailable': 'Kursinfo nicht verfügbar',
      'separator': '.',
      'decimal': ',',
      'hours': ' Std',
      'mins': ' Min'
    },
    'es-es': {
      'overview': 'Descripción del curso',
      'enrolled': 'estudiantes',
      'updated': 'Última actualización ',
      'notavailable': 'La información del curso no está disponible',
      'separator': '.',
      'decimal': ',',
      'hours': ' h',
      'mins': ' m'
    },
    'fr-fr': {
      'overview': 'Aperçu du cours',
      'enrolled': 'participants',
      'updated': 'Dernière mise à jour : ',
      'notavailable': 'Informations sur les cours non disponibles',
      'separator': ' ',
      'decimal': ',',
      'hours': ' h',
      'mins': ' min'
    },
    'it-it': {
      'overview': 'Panoramica del corso',
      'enrolled': 'studenti',
      'updated': 'Ultimo aggiornamento ',
      'notavailable': 'Informazioni sul corso non disponibili',
      'separator': '.',
      'decimal': ',',
      'hours': ' h',
      'mins': ' min'
    },
    'ja-jp': {
      'overview': 'コースの概要',
      'enrolled': '受講生',
      'updated': '最終更新日 ',
      'notavailable': 'コースの情報はありません。',
      'separator': ',',
      'decimal': '.',
      'hours': '時間',
      'mins': '分'
    }
  };
}

function getFlagEmoji(countryCode) {
  const codePoints = countryCode
    .split('')
    .map(char => 127397 + char.charCodeAt());
  return String.fromCodePoint(...codePoints);
}

const style = document.createElement('style');
style.textContent = `
.improved-course-card--container {
  border: 1px solid #d1d7dc;
}

.improved-course-card--container:hover {
  background-color: #f7f9fa;
}

.improved-course-card--container .improved-course-card--image-container {
  border-width: 0 0 1px 0;
}

.improved-course-card--main-content {
  padding: 0 6px;
  min-height: 68px;
}

.card--learning__details {
  border-top: 1px solid #e8e9eb;
}

.card__details {
  padding: 12px;
  height: 66px;
  white-space: initial;
}

.improved-course-card--course-title {
  font-size: 1.4rem !important;
}

span[class^='leave-rating--helper-text'] {
  font-size: 10px;
  white-space: nowrap;
}

.card__thumb-overlay {
  position: absolute;
  display: inline-block;
  font-size: 10px;
  font-weight: 700;
  margin: 4px;
  padding: 2px 4px;
  border-radius: 2px;
  transition: opacity linear 100ms;
}

.card__course-link {
  font-size: 1.4rem;
}

.card__course-runtime {
  bottom: 0;
  right: 0;
  background-color: rgba(20, 30, 46, 0.75);
  color: #ffffff;
}

.impr__progress-bar ~ .card__course-runtime {
  bottom: 4px;
}

.card__course-locale {
  top: 0;
  left: 0;
  background-color: rgba(255, 255, 255, 0.9);
  box-shadow: 0 0 1px 1px rgba(20, 23, 28, 0.1);
  color: #29303b;
  font-weight: 600;
}

.improved-course-card--container .hover-hide {
  opacity: 1;
}

.improved-course-card--container .hover-show {
  opacity: 0;
}

.improved-course-card--container:hover .hover-hide {
  opacity: 0;
}

.improved-course-card--container:hover .hover-show {
  opacity: 1;
}

.impr__progress-bar {
  display: block;
  position: absolute;
  bottom: 0;
  right: 0;
  left: 0;
  height: 5px;
  background: rgba(20, 30, 46, 0.75);
}

.impr__progress-bar .progress__bar {
  background: #a435ef !important;
}

.improved-course-card--additional-details {
  width: 100%;
  font-size: 1.2rem;
  color: #464b53;
  height: 82px;
}

.impr__rating .impr__rating-number {
  margin-left: 0.4rem;
  font-size: 1.3rem;
  color: #505763;
}

.impr__rating-count {
  color: #6a6f73;
  margin-left: 0.4rem;
}

.impr__rating {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding: 0 6px 6px;
  height: 42px;
}

.impr__rating-strip {
  height: 5px;
}

.impr__stats {
  font-weight: 500;
  padding: 6px;
  line-height: 1.7;
  display: flex;
}

.impr__badge {
  display: inline-flex;
  position: relative;
  flex-direction: row;
  justify-content: center;
  align-items: center;
  gap: 4px;
  background: #f7f8fa;
  padding: 0 5px;
  margin-right: 5px;
  border-radius: 2px;
  border: 1px solid #e7e7e8;
  cursor: default;
}

.impr__badge .ud-icon {
  width: 1.4rem;
  height: 1.4rem;
  opacity: 0.75;
}

.impr__svg-stars {
  display: block;
  width: 7rem;
  height: 1.6rem;
}

.card__nodata {
  font-size: 13px;
  display: flex;
  justify-content: center;
  align-items: center;
  text-align: center;
  height: 75px;
  margin-top: 10px;
  padding: 12px;
  background: #fbf4f4;
  color: #521822;
}

.impr__badge:hover:after {
  display: flex;
  justify-content: center;
  background: #4f5662;
  border-radius: 3px;
  color: #fff;
  content: attr(data-tooltip);
  bottom: 24px;
  margin: 0;
  font-size: 11px;
  padding: 2px 6px;
  position: absolute;
  z-index: 10;
  white-space: pre;
}

.impr__badge:hover:before {
  border: solid;
  border-color: #4f5662 transparent;
  content: '';
  left: 50%;
  margin-left: -4px;
  position: absolute;
  top: -4px;
  border-width: 6px 4px 0;
}

.impr__rating-row {
  margin: 0;
  padding: 0;
  display: flex;
}

.impr__star-wrapper {
  display: inline-flex;
  align-items: center;
}`;
document.documentElement.appendChild(style);