Greasy Fork is available in English.

Udemy - Improved Course Library

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

Version au 22/04/2022. Voir la dernière version.

// ==UserScript==
// @name         Udemy - Improved Course Library
// @namespace    https://github.com/tadwohlrapp
// @description  Adds current ratings, and other detailed data to all courses in your Udemy library
// @icon         https://raw.githubusercontent.com/tadwohlrapp/udemy-improved-course-library/main/src/icon48.png
// @icon64       https://raw.githubusercontent.com/tadwohlrapp/udemy-improved-course-library/main/src/icon64.png
// @author       Tad Wohlrapp (https://github.com/tadwohlrapp)
// @homepageURL  https://github.com/tadwohlrapp/udemy-improved-course-library
// @version      1.0.4
// @supportURL   https://github.com/tadwohlrapp/udemy-improved-course-library/issues
// @match        https://www.udemy.com/home/my-courses/*
// @compatible   chrome Tested with Tampermonkey v4.13 and Violentmonkey v2.13.0
// @compatible   firefox Tested with Greasemonkey v4.11
// @license      MIT
// @run-at       document-end
// ==/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('[data-purpose="enrolled-course-card"]:not(.details-done)');
  if (courseContainers.length == 0) { return; }
  [...courseContainers].forEach((courseContainer) => {

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

    const courseId = courseContainer.querySelector('.card--learning__image').href.replace('https://www.udemy.com/course-dashboard-redirect/?course_id=', '');

    const courseCustomDiv = document.createElement('div');
    courseCustomDiv.classList.add('card__custom', 'js-removepartial');

    courseContainer.appendChild(courseCustomDiv);
    courseContainer.classList.add('details-done');
    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.querySelectorAll('.udlite-block-list');
    if (allDropdowns[1]) {
      allDropdowns[1].appendChild(courseLinkLi);;
    }

    // Find existing elements in DOM
    const thumbnailDiv = courseContainer.querySelector('.card__image');
    const detailsName = courseContainer.querySelector('.details__name');
    const detailsInstructor = courseContainer.querySelector('.details__instructor');
    const progressText = courseContainer.querySelector('.progress__text');
    const progressBar = courseContainer.querySelector('.details__progress');
    const startCourseText = courseContainer.querySelector('.details__start-course');
    const detailsBottom = courseContainer.querySelector('.details__bottom');

    // If progress made
    if (progressText != null) {
      // Add progress bar below thumbnail
      const progressBarSpan = document.createElement('span');
      progressBarSpan.classList.add('impr__progress-bar', 'js-removepartial');
      progressBarSpan.innerHTML = progressBar.innerHTML;
      thumbnailDiv.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.innerHTML = progressText.innerHTML;
      thumbnailDiv.appendChild(progressTextSpan);
      // Remove existing progress percentage
      progressText.parentNode.removeChild(progressText);
    }

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

    // If "START COURSE" exists, remove it. It's clutter
    if (startCourseText != null) {
      startCourseText.parentNode.removeChild(startCourseText);
    }

    if (!isPartialRefresh) {
      // If instructor title exists, remove it as well
      const instructorTitle = detailsInstructor.querySelector('span');
      if (instructorTitle != null) {
        instructorTitle.parentNode.removeChild(instructorTitle);
      }

      // Switch classes on course name and instructor
      detailsName.classList.add('impr__name');
      detailsName.classList.remove('details__name');
      detailsInstructor.classList.add('impr__instructor');
      detailsInstructor.classList.remove('details__instructor');
    }

    // If course page has draft status, do not even to fetch its data via API
    if (courseContainer.querySelector('.card--learning__details .card__details a').href.includes('/draft/')) {
      if (!isPartialRefresh) {
        detailsBottom.parentNode.removeChild(detailsBottom);
      }
      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,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 updateDate = json.last_update_date;
        const locale = json.locale.title;
        const localeCode = json.locale.locale;
        const hasCaptions = json.has_closed_caption;
        const captionsLangs = json.caption_languages;

        // Format "Last updated" Date
        let updateDateShort = '';
        let updateDateLong = '';
        if (updateDate) {
          updateDateShort = updateDate.replace(/(\d{4})-(\d{2})-(\d{2})/, '$2\/$1');
          updateDateLong = new Date(updateDate).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__tooltip" data-tooltip="${captionsString}">
              <i class="udi udi-closed-caption"></i>
            </div>
          `;
        }

        // Returns true or false depending if stars are visible
        const isShowingStars = courseContainer.querySelector('.details__bottom--review');

        // 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 (isShowingStars != null) {

          // Find the rating-button, and remove its css class
          ratingButton = isShowingStars.querySelector('button');

          // 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 = `
            <span class="impr__stars-ct">
              <span class="impr__stars">
                ${buildStars(ratingOwn)}
              </span>
              <span class="impr__review">
                <span class="impr__review-stat">${setDecimal(ratingOwn, lang)}</span>
                <span class="impr__review-count">(<span class="review-button"></span>)</span>
              </span>
            </span>
          `;
        }

        const ratingStripColor = ratingOwn > 0 ? ratingOwn : rating;

        let updateDateInfo = '';
        if (updateDateShort !== '' && updateDateLong !== '') {
          updateDateInfo = `
            <div class="impr__tooltip" data-tooltip="${i18n[lang].updated}${updateDateLong}">
              <i class="udi udi-resend"></i><span>${updateDateShort}</span>
            </div>
          `;
        }

        courseCustomDiv.innerHTML = `
          <div class="impr__rating">
            <span class="impr__stars-ct">
              <span class="impr__stars">
                ${buildStars(rating)}
              </span>
              <span class="impr__review">
                <span class="impr__review-stat">${setDecimal(rating, lang)}</span>
                <span class="impr__review-count">(${setSeparator(reviews, lang)})</span>
              </span>
            </span>
            ${myRatingHtml}
          </div>
          <div class="impr__rating-strip" style="background-color:${getColor(colorValue(ratingStripColor))}"></div>
          <div class="impr__stats">
            <div class="impr__tooltip" data-tooltip="${setSeparator(enrolled, lang)} ${i18n[lang].enrolled}">
              <i class="udi udi-users"></i><span>${setSeparator(enrolled, lang)}</span>
            </div>
            ${updateDateInfo}
            ${captionsTag}
          </div>
        `;

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

        if (!isPartialRefresh) {
          detailsBottom.parentNode.removeChild(detailsBottom);
        }

        // 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}`;
          thumbnailDiv.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);
        thumbnailDiv.appendChild(runtimeSpan);
      })
      .catch(error => {
        courseCustomDiv.classList.add('card__nodata');
        courseCustomDiv.innerHTML += `<div><b>${error}</b><br>${i18n[lang].notavailable}</div>`;
        if (detailsBottom != null) {
          detailsBottom.parentNode.removeChild(detailsBottom);
        }
      });
  });
}

function listenForArchiveToggle() {
  document.querySelectorAll('[data-purpose="toggle-archived"]').forEach(item => {
    item.addEventListener('click', event => {
      mutationObserver.disconnect();
      let thisCourse = item.closest('.details-done');
      if (thisCourse != null) {
        thisCourse.classList.add('partial-refresh');

        while (thisCourse.nextElementSibling != null) {
          thisCourse.nextElementSibling.classList.add('partial-refresh');
          thisCourse = thisCourse.nextElementSibling;
        }
      }

      const brokenContainers = document.querySelectorAll('.partial-refresh');
      [...brokenContainers].forEach((brokenContainer) => {
        brokenContainer.classList.remove('details-done');
        let removeElements = brokenContainer.getElementsByClassName('js-removepartial');
        while (removeElements[0]) {
          removeElements[0].parentNode.removeChild(removeElements[0]);
        }
      });

      mutationObserver.observe(document, observerConfig);
    });
  });
}

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 buildStars(rating) {
  let starTemplate = '';
  let remainder = 0;
  for (let i = 0; i < 5; i++) {
    let percent = 0;
    if (Math.floor(rating) > i) {
      percent = 100;
    } else if (remainder == 0) {
      remainder = rating % 1;
      percent = Math.round(remainder * 10) * 10
    }
    starTemplate += `
      <div>
        <span class="impr__star impr__star--unfilled"></span>
        <span class="impr__star impr__star--filled" style="width: ${percent.toString()}%;"></span>
      </div>
    `;
  }
  return starTemplate;
}

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 = `
.card--learning {
  box-shadow: 0 0 1px 1px rgba(20, 23, 28, 0.1),
    0 3px 1px 0 rgba(20, 23, 28, 0.1);
  transition: all 100ms linear;
}

.card--learning:hover {
  box-shadow: 0 2px 8px 2px rgba(20, 23, 28, 0.15);
}

.card--learning:before {
  content: none;
}

.card--learning:after {
  content: none;
}

.card__image {
  overflow: inherit;
}

.card__image .course-image {
  transition: opacity linear 100ms;
  box-shadow: 0 1px 0 0 rgba(232, 233, 235, 0.5);
  -webkit-filter: sepia(0.1) grayscale(0.1) saturate(0.8);
  filter: sepia(0.1) grayscale(0.1) saturate(0.8);
}

a:hover .card__image .course-image {
  opacity: 0.8;
}

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

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

.impr__name {
  font-weight: 700;
  line-height: 1.2;
  letter-spacing: -0.2px;
  font-size: 14px;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.impr__instructor {
  color: #73726c;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  margin-top: 4px;
  font-size: 12px;
}

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;
}

.play-button-trigger .hover-hide {
  opacity: 1;
}

.play-button-trigger .hover-show {
  opacity: 0;
}

.play-button-trigger:hover .hover-hide {
  opacity: 0;
}

.play-button-trigger: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;
}

.card__custom {
  font-size: 12px;
  color: #464b53;
  height: 85px;
}

.impr__rating {
  padding: 0 12px;
  height: 48px;
}

.impr__rating-strip {
  height: 5px;
}

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

.impr__stats > div {
  display: inline-block;
  background: #f7f8fa;
  padding: 0 5px;
  margin-right: 5px;
  border-radius: 2px;
  border: 1px solid #e7e7e8;
  cursor: default;
}

.impr__stats .udi {
  opacity: 0.75;
  vertical-align: middle;
}

.impr__stats .udi:not(:last-child) {
  margin-right: 4px;
}

.impr__star-own {
  font-size: 16px;
  margin-right: 2px;
}

.card__stars {
  display: inline-block;
  width: 7rem;
  height: 1.6rem;
  vertical-align: text-bottom;
}

.card__star--bordered {
  stroke: #eb8a2f;
}

.card__star--filled {
  fill: #eb8a2f;
}

.card__rating-text {
  font-weight: 700;
  color: #505763;
  margin-left: 2px;
  margin-right: 6px;
  font-size: 14px;
}

.impr__icon {
  width: 12px;
  height: 15px;
  fill: currentColor;
  vertical-align: text-top;
  margin-right: 4px;
  opacity: 0.75;
}

.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__rating-all {
  height: 20px;
}

.impr__rating-btn {
  position: relative;
  height: 20px;
  flex-direction: row !important;
  align-items: center !important;
  justify-content: flex-end;
}

.impr__rating-btn > .impr__rating-own {
  position: absolute;
}

.impr__rating-btn.is-rated > span {
  opacity: 0;
}

.impr__rating-btn.is-rated:hover > span {
  opacity: 1;
}

.impr__rating-btn.is-rated:hover > .impr__rating-own {
  opacity: 0;
}

.impr__tooltip {
  display: inline;
  position: relative;
}

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

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

.impr__stars-ct {
  margin: 0;
  padding: 4px 0 0;
  display: flex;
}

.impr__stars {
  font-size: 13px;
  display: inline-block;
  white-space: nowrap;
}

.impr__stars div {
  display: inline-block;
  position: relative;
}

.impr__star {
  top: 0;
  left: 0;
}

.impr__star:before {
  font-family: udemyicons;
  display: inline-block;
  position: relative;
  line-height: 1;
}

.impr__star--unfilled {
  position: relative;
}

.impr__star--unfilled:before {
  z-index: 0;
  content: '\\F005';
  color: #dedfe0;
}

.impr__star--filled {
  position: absolute;
  overflow: hidden;
}

.impr__star--filled:before {
  z-index: 1;
  content: '\\F005';
  color: #f4c150;
}

.impr__review {
  margin-left: 5px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.impr__review-stat {
  font-weight: 700;
  font-size: 13px;
  color: #505763;
}

.impr__review-count {
  font-weight: 400;
  color: #686f7a;
  margin-left: 2px;
}`;
document.documentElement.appendChild(style);