// ==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.3
// @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
// @downloadURL none
// ==/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 = `
`;
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').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('
');
captionsTag = `
`;
}
// 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) {
// If I have voted, count the stars and tell me how I voted
ratingOwn = countAllWidths(isShowingStars.querySelectorAll('[style]')); // between 0 and 5
// Find the rating-button, and remove its css class
ratingButton = isShowingStars.querySelector('[role="button"]');
// Remove the old stars from ratingButton
ratingButton.removeChild(ratingButton.querySelector('.star-rating-shell'));
// Build the html
myRatingHtml = `
${buildStars(ratingOwn)}
${setDecimal(ratingOwn, lang)}
()
`;
}
const ratingStripColor = ratingOwn > 0 ? ratingOwn : rating;
let updateDateInfo = '';
if (updateDateShort !== '' && updateDateLong !== '') {
updateDateInfo = `
${updateDateShort}
`;
}
courseCustomDiv.innerHTML = `
${buildStars(rating)}
${setDecimal(rating, lang)}
(${setSeparator(reviews, lang)})
${myRatingHtml}
${setSeparator(enrolled, lang)}
${updateDateInfo}
${captionsTag}
`;
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 = `${getFlagEmoji(localeCode.slice(-2))}${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 += `${error}
${i18n[lang].notavailable}
`;
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 += `
`;
}
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 countAllWidths(elements) {
let rating = 0;
for (let i = 0; i < elements.length; i++) {
if (!elements[i].hasAttribute('style')) return false;
if (elements[i].getAttribute('style') === 'width: 100%;') { rating += 1; }
if (elements[i].getAttribute('style') === 'width: 50%;') { rating += 0.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'
}
};
}
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);