// ==UserScript== // @name Filterboxd // @namespace https://github.com/blakegearin/filterboxd // @version 1.5.0 // @description Filter content on Letterboxd // @author Blake Gearin (https://blakegearin.com) // @match https://letterboxd.com/* // @require https://openuserjs.org/src/libs/sizzle/GM_config.js // @grant GM.getValue // @grant GM.setValue // @icon https://raw.githubusercontent.com/blakegearin/filterboxd/main/img/logo.png // @supportURL https://github.com/blakegearin/filterboxd/issues // @license MIT // @copyright 2024–2025, Blake Gearin (https://blakegearin.com) // @downloadURL https://update.greasyfork.icu/scripts/519719/Filterboxd.user.js // @updateURL https://update.greasyfork.icu/scripts/519719/Filterboxd.meta.js // ==/UserScript== /* jshint esversion: 6 */ /* global GM_config */ (function() { 'use strict'; const VERSION = '1.5.0'; const USERSCRIPT_NAME = 'Filterboxd'; let GMC = null; // Log levels const SILENT = 0; const QUIET = 1; const INFO = 2; const DEBUG = 3; const VERBOSE = 4; const TRACE = 5; // Change to true if you want to clear all your local data; this is irreversible const RESET_DATA = false; const LOG_LEVELS = { default: 'quiet', options: [ 'silent', 'quiet', 'info', 'debug', 'verbose', 'trace', ], getName: (level) => { return { 0: 'silent', 1: 'quiet', 2: 'info', 3: 'debug', 4: 'verbose', 5: 'trace', }[level]; }, getValue: (name) => { return { silent: SILENT, quiet: QUIET, info: INFO, debug: DEBUG, verbose: VERBOSE, trace: TRACE, }[name]; }, }; function currentLogLevel() { if (GMC === null) return LOG_LEVELS.getValue(LOG_LEVELS.default); return LOG_LEVELS.getValue(GMC.get('logLevel')); } function log (level, message, variable = undefined) { if (currentLogLevel() < level) return; const levelName = LOG_LEVELS.getName(level); const log = `[${VERSION}] [${levelName}] ${USERSCRIPT_NAME}: ${message}`; console.groupCollapsed(log); if (variable !== undefined) console.dir(variable, { depth: null }); console.trace(); console.groupEnd(); } function logError (message, error = undefined) { const log = `[${VERSION}] [error] ${USERSCRIPT_NAME}: ${message}`; console.groupCollapsed(log); if (error !== undefined) console.error(error); console.trace(); console.groupEnd(); } log(TRACE, 'Starting'); function gmcGet(key) { log(DEBUG, 'gmcGet()'); try { return GMC.get(key); } catch (error) { logError(`Error setting GMC, key=${key}`, error); } } function gmcSet(key, value) { log(DEBUG, 'gmcSet()'); try { return GMC.set(key, value); } catch (error) { logError(`Error setting GMC, key=${key}, value=${value}`, error); } } function gmcSave() { log(DEBUG, 'gmcSave()'); try { return GMC.save(); } catch (error) { logError('Error saving GMC', error); } } function startObserving() { log(DEBUG, 'startObserving()'); OBSERVER.observe( document.body, { childList: true, subtree: true, }, ); } function modifyThenObserve(callback) { log(DEBUG, 'modifyThenObserve()'); OBSERVER.disconnect(); callback(); startObserving(); } function mutationsExceedsLimits() { // Fail-safes to prevent infinite loops if (IDLE_MUTATION_COUNT > gmcGet('maxIdleMutations')) { logError('Max idle mutations exceeded'); OBSERVER.disconnect(); return true; } else if (ACTIVE_MUTATION_COUNT >= gmcGet('maxActiveMutations')) { logError('Max active mutations exceeded'); OBSERVER.disconnect(); return true; } return false; } function observeAndModify(mutationsList) { log(VERBOSE, 'observeAndModify()'); if (mutationsExceedsLimits()) return; log(VERBOSE, 'mutationsList.length', mutationsList.length); for (const mutation of mutationsList) { if (mutation.type !== 'childList') return; log(TRACE, 'mutation', mutation); let sidebarUpdated; let popMenuUpdated; let filtersApplied; modifyThenObserve(() => { sidebarUpdated = maybeAddListItemToSidebar(); log(VERBOSE, 'sidebarUpdated', sidebarUpdated); popMenuUpdated = addListItemToPopMenu(); log(VERBOSE, 'popMenuUpdated', popMenuUpdated); filtersApplied = applyFilters(); log(VERBOSE, 'filtersApplied', filtersApplied); }); const activeMutation = sidebarUpdated || popMenuUpdated || filtersApplied; log(DEBUG, 'activeMutation', activeMutation); if (activeMutation) { ACTIVE_MUTATION_COUNT++; log(VERBOSE, 'ACTIVE_MUTATION_COUNT', ACTIVE_MUTATION_COUNT); } else { IDLE_MUTATION_COUNT++; log(VERBOSE, 'IDLE_MUTATION_COUNT', IDLE_MUTATION_COUNT); } if (mutationsExceedsLimits()) break; } } // Source: https://stackoverflow.com/a/21144505/5988852 function countWords(string) { var matches = string.match(/[\w\d’'-]+/gi); return matches ? matches.length : 0; } function createId(string) { log(TRACE, 'createId()'); if (string.startsWith('#')) return string; if (string.startsWith('.')) { logError(`Attempted to create an id from a class: "${string}"`); return; } if (string.startsWith('[')) { logError(`Attempted to create an id from an attribute selector: "${string}"`); return; } return `#${string}`; } const FILM_BEHAVIORS = [ 'Remove', 'Fade', 'Blur', 'Replace poster', 'Custom', ]; const REVIEW_BEHAVIORS = [ 'Remove', 'Fade', 'Blur', 'Replace text', 'Custom', ]; const COLUMN_ONE_WIDTH = '33%'; const COLUMN_TWO_WIDTH = '64.8%'; const COLUMN_HALF_WIDTH = '50%'; let IDLE_MUTATION_COUNT = 0; let ACTIVE_MUTATION_COUNT = 0; let SELECTORS = { filmPosterPopMenu: { self: '.film-poster-popmenu', userscriptListItemClass: 'filterboxd-list-item', addToList: '.film-poster-popmenu .menu-item-add-to-list', addThisFilm: '.film-poster-popmenu .menu-item-add-this-film', }, filmPageSections: { backdropImage: 'body.backdrop-loaded .backdrop-container', // Left column poster: '#film-page-wrapper section.poster-list a[data-js-trigger="postermodal"]', stats: '#film-page-wrapper section.poster-list ul.film-stats', whereToWatch: '#film-page-wrapper section.watch-panel', // Right column userActionsPanel: '#film-page-wrapper section#userpanel', ratings: '#film-page-wrapper section.ratings-histogram-chart', // Middle column releaseYear: '#film-page-wrapper .details .releaseyear', director: '#film-page-wrapper .details .credits', tagline: '#film-page-wrapper .tagline', description: '#film-page-wrapper .truncate', castTab: '#film-page-wrapper #tabbed-content ul li:nth-of-type(1),#film-page-wrapper #tab-cast', crewTab: '#film-page-wrapper #tabbed-content ul li:nth-of-type(2),#film-page-wrapper #tab-crew', detailsTab: '#film-page-wrapper #tabbed-content ul li:nth-of-type(3),#film-page-wrapper #tab-details', genresTab: '#film-page-wrapper #tabbed-content ul li:nth-of-type(4),#film-page-wrapper #tab-genres', releasesTab: '#film-page-wrapper #tabbed-content ul li:nth-of-type(5),#film-page-wrapper #tab-releases', activityFromFriends: '#film-page-wrapper section.activity-from-friends', filmNews: '#film-page-wrapper section.film-news', reviewsFromFriends: '#film-page-wrapper section#popular-reviews-with-friends', popularReviews: '#film-page-wrapper section#popular-reviews', recentReviews: '#film-page-wrapper section#recent-reviews', relatedFilms: '#film-page-wrapper section#related', similarFilms: '#film-page-wrapper section.related-films:not(#related)', mentionedBy: '#film-page-wrapper section#film-hq-mentions', popularLists: '#film-page-wrapper section:has(#film-popular-lists)', }, filter: { filmClass: 'filterboxd-filter-film', reviewClass: 'filterboxd-filter-review', reviews: { ratings: '.film-detail .attribution .rating,.film-detail-meta .rating,.activity-summary .rating,.film-metadata .rating,.-rated .rating,.poster-viewingdata .rating', likes: '.film-detail .attribution .icon-liked,.film-metadata .icon-liked,.review .like-link-target,.film-detail-content .like-link-target', comments: '.film-detail .attribution .content-metadata,#content #comments', withSpoilers: '.film-detail:has(.contains-spoilers)', withoutRatings: '.film-detail:not(:has(.rating))', }, }, homepageSections: { friendsHaveBeenWatching: '.person-home h1.title-hero span', newFromFriends: '.person-home section#recent-from-friends', popularWithFriends: '.person-home section#popular-with-friends', discoveryStream: '.person-home section.section-discovery-stream', latestNews: '.person-home section#latest-news:not(:has(.teaser-grid))', popularReviewsWithFriends: '.person-home section#popular-reviews', newListsFromFriends: '.person-home section:has([href="/lists/friends/"])', popularLists: '.person-home section:has([href="/lists/popular/this/week/"])', recentStories: '.person-home section.stories-section', recentShowdowns: '.person-home section:has([href="/showdown/"])', recentNews: '.person-home section#latest-news:has(.teaser-grid)', }, processedClass: { apply: 'filterboxd-hide-processed', remove: 'filterboxd-unhide-processed', }, settings: { clear: '.clear', favoriteFilms: '.favourite-films-selector', filteredTitleLinkClass: 'filtered-title-span', note: '.note', posterList: '.poster-list', removePendingClass: 'remove-pending', savedBadgeClass: 'filtered-saved', subNav: '.sub-nav', subtitle: '.mob-subtitle', tabbedContentId: '#tabbed-content', }, userpanel: { self: '#userpanel', userscriptListItemId: 'filterboxd-userpanel-list-item', addThisFilm: '#userpanel .add-this-film', }, }; function addListItemToPopMenu() { log(DEBUG, 'addListItemToPopMenu()'); const filmPosterPopMenus = document.querySelectorAll(SELECTORS.filmPosterPopMenu.self); if (!filmPosterPopMenus) { log(`Selector ${SELECTORS.filmPosterPopMenu.self} not found`, DEBUG); return false; } let pageUpdated = false; filmPosterPopMenus.forEach(filmPosterPopMenu => { const userscriptListItemPresent = filmPosterPopMenu.querySelector( `.${SELECTORS.filmPosterPopMenu.userscriptListItemClass}`, ); if (userscriptListItemPresent) return; const lastListItem = filmPosterPopMenu.querySelector('li:last-of-type'); if (!lastListItem) { logError(`Selector ${SELECTORS.filmPosterPopMenu} li:last-of-type not found`); return; } const unorderedList = filmPosterPopMenu.querySelector('ul'); if (!unorderedList) { logError(`Selector ${SELECTORS.filmPosterPopMenu.self} ul not found`); return; } modifyThenObserve(() => { let userscriptListItem = lastListItem.cloneNode(true); userscriptListItem.classList.add(SELECTORS.filmPosterPopMenu.userscriptListItemClass); userscriptListItem = buildUserscriptLink(userscriptListItem, unorderedList); lastListItem.parentNode.append(userscriptListItem); }); pageUpdated = true; }); return pageUpdated; } function addFilterToFilm({ id, slug }) { log(DEBUG, 'addFilterToFilm()'); let pageUpdated = false; const idMatch = `[data-film-id="${id}"]`; let appliedSelector = `.${SELECTORS.processedClass.apply}`; const replaceBehavior = gmcGet('filmBehaviorType') === 'Replace poster'; log(VERBOSE, 'replaceBehavior', replaceBehavior); if (replaceBehavior) appliedSelector = '[data-original-img-src]'; log(VERBOSE, 'Activity page reviews'); document.querySelectorAll(`section.activity-row ${idMatch}`).forEach(posterElement => { applyFilterToFilm(posterElement, 3); pageUpdated = true; }); log(VERBOSE, 'Activity page likes'); document.querySelectorAll(`section.activity-row .activity-summary a[href*="${slug}"]:not(${appliedSelector})`).forEach(posterElement => { applyFilterToFilm(posterElement, 3); pageUpdated = true; }); log(VERBOSE, 'New from friends'); document.querySelectorAll(`.poster-container ${idMatch}:not(${appliedSelector})`).forEach(posterElement => { applyFilterToFilm(posterElement, 1); pageUpdated = true; }); log(VERBOSE, 'Reviews'); document.querySelectorAll(`.review-tile ${idMatch}:not(${appliedSelector})`).forEach(posterElement => { applyFilterToFilm(posterElement, 3); pageUpdated = true; }); log(VERBOSE, 'Diary'); document.querySelectorAll(`.td-film-details [data-original-img-src]${idMatch}:not(${appliedSelector})`).forEach(posterElement => { applyFilterToFilm(posterElement, 2); pageUpdated = true; }); log(VERBOSE, 'Popular with friends, competitions'); const remainingElements = document.querySelectorAll( `div:not(.popmenu):not(.actions-panel) ${idMatch}:not(aside [data-film-id="${id}"]):not(${appliedSelector})`, ); remainingElements.forEach(posterElement => { applyFilterToFilm(posterElement, 0); pageUpdated = true; }); return pageUpdated; } function addToHiddenTitles(filmMetadata) { log(DEBUG, 'addToHiddenTitles()'); const filmFilter = getFilter('filmFilter'); filmFilter.push(filmMetadata); log(VERBOSE, 'filmFilter', filmFilter); setFilter('filmFilter', filmFilter); } function applyFilters() { log(DEBUG, 'applyFilters()'); let pageUpdated = false; const filmFilter = getFilter('filmFilter'); log(VERBOSE, 'filmFilter', filmFilter); const reviewFilter = getFilter('reviewFilter'); log(VERBOSE, 'reviewFilter', reviewFilter); const replaceBehavior = gmcGet('reviewBehaviorType') === 'Replace text'; log(VERBOSE, 'replaceBehavior', replaceBehavior); const reviewBehaviorReplaceValue = gmcGet('reviewBehaviorReplaceValue'); log(VERBOSE, 'reviewBehaviorReplaceValue', reviewBehaviorReplaceValue); const homepageFilter = getFilter('homepageFilter'); log(VERBOSE, 'homepageFilter', homepageFilter); const filmPageFilter = getFilter('filmPageFilter'); log(VERBOSE, 'filmPageFilter', filmPageFilter); modifyThenObserve(() => { filmFilter.forEach(filmMetadata => { const filmUpdated = addFilterToFilm(filmMetadata); if (filmUpdated) pageUpdated = true; }); const selectorReviewElementsToFilter = []; if (reviewFilter.ratings) selectorReviewElementsToFilter.push(SELECTORS.filter.reviews.ratings); if (reviewFilter.likes) selectorReviewElementsToFilter.push(SELECTORS.filter.reviews.likes); if (reviewFilter.comments) selectorReviewElementsToFilter.push(SELECTORS.filter.reviews.comments); log(VERBOSE, 'selectorReviewElementsToFilter', selectorReviewElementsToFilter); if (selectorReviewElementsToFilter.length) { document.querySelectorAll(selectorReviewElementsToFilter.join(',')).forEach(reviewElement => { reviewElement.style.display = 'none'; pageUpdated = true; }); } const reviewsToFilterSelectors = []; if (reviewFilter.withSpoilers) reviewsToFilterSelectors.push(SELECTORS.filter.reviews.withSpoilers); if (reviewFilter.withoutRatings) reviewsToFilterSelectors.push(SELECTORS.filter.reviews.withoutRatings); log(VERBOSE, 'reviewsToFilterSelectors', reviewsToFilterSelectors); if (reviewsToFilterSelectors.length) { document.querySelectorAll(reviewsToFilterSelectors.join(',')).forEach(review => { if (replaceBehavior) { review.querySelector('.body-text').innerText = reviewBehaviorReplaceValue; } review.classList.add(SELECTORS.filter.reviewClass); pageUpdated = true; }); } if (reviewFilter.byWordCount) { const reviewMinimumWordCount = getFilter('reviewMinimumWordCount'); log(VERBOSE, 'reviewMinimumWordCount', reviewMinimumWordCount); document.querySelectorAll('.film-detail:not(.filterboxd-filter-review)').forEach(review => { const reviewText = review.querySelector('.body-text').innerText; log(VERBOSE, 'reviewText', reviewText); if (countWords(reviewText) >= reviewMinimumWordCount) return; if (replaceBehavior) { review.querySelector('.body-text').innerText = reviewBehaviorReplaceValue; } review.classList.add(SELECTORS.filter.reviewClass); pageUpdated = true; }); } const sectionsToFilter = []; const homepageSectionsToFilter = Object.keys(homepageFilter) .filter(key => homepageFilter[key]) .map(key => SELECTORS.homepageSections[key]) .filter(Boolean); log(VERBOSE, 'homepageSectionToFilter', homepageSectionsToFilter); const filmPageSectionsToFilter = Object.keys(filmPageFilter) .filter(key => filmPageFilter[key]) .map(key => SELECTORS.filmPageSections[key]) .filter(Boolean); log(VERBOSE, 'filmPageSectionsToFilter', filmPageSectionsToFilter); sectionsToFilter.push(...homepageSectionsToFilter); sectionsToFilter.push(...filmPageSectionsToFilter); if (sectionsToFilter.length) { document.querySelectorAll(sectionsToFilter.join(',')).forEach(filterSection => { filterSection.style.display = 'none'; pageUpdated = true; }); } if (filmPageFilter.backdropImage) { document.querySelector('#content.-backdrop')?.classList.remove('-backdrop'); pageUpdated = true; } }); return pageUpdated; } function applyFilterToFilm(element, levelsUp = 0) { log(DEBUG, 'applyFilterToFilm()'); const replaceBehavior = gmcGet('filmBehaviorType') === 'Replace poster'; log(VERBOSE, 'replaceBehavior', replaceBehavior); if (replaceBehavior) { const filmBehaviorReplaceValue = gmcGet('filmBehaviorReplaceValue'); log(VERBOSE, 'filmBehaviorReplaceValue', filmBehaviorReplaceValue); const elementImg = element.querySelector('img'); if (!elementImg) return; const originalImgSrc = elementImg.src; if (!originalImgSrc) return; if (originalImgSrc === filmBehaviorReplaceValue) return; element.setAttribute('data-original-img-src', originalImgSrc); element.querySelector('img').src = filmBehaviorReplaceValue; element.querySelector('img').srcset = filmBehaviorReplaceValue; element.classList.add(SELECTORS.processedClass.apply); element.classList.remove(SELECTORS.processedClass.remove); } else { let target = element; for (let i = 0; i < levelsUp; i++) { if (target.parentNode) { target = target.parentNode; } else { break; } } log(VERBOSE, 'target', target); target.classList.add(SELECTORS.filter.filmClass); element.classList.add(SELECTORS.processedClass.apply); element.classList.remove(SELECTORS.processedClass.remove); } } function buildBehaviorFormRows(parentDiv, filterName, selectArrayValues, behaviorsMetadata) { const behaviorValue = gmcGet(`${filterName}BehaviorType`); log(DEBUG, 'behaviorValue', behaviorValue); const behaviorChange = (event) => { const filmBehaviorType = event.target.value; updateBehaviorCSSVariables(filterName, filmBehaviorType); }; const behaviorFormRow = createFormRow({ formRowStyle: `width: ${COLUMN_ONE_WIDTH};`, labelText: 'Behavior', inputValue: behaviorValue, inputType: 'select', selectArray: selectArrayValues, selectOnChange: behaviorChange, }); parentDiv.appendChild(behaviorFormRow); // Fade amount const behaviorFadeAmount = parseInt(gmcGet(behaviorsMetadata.fade.fieldName)); log(DEBUG, 'behaviorFadeAmount', behaviorFadeAmount); const fadeAmountFormRow = createFormRow({ formRowClass: ['update-details'], formRowStyle: `width: ${COLUMN_TWO_WIDTH}; float: right; display: var(--filterboxd-${filterName}-behavior-fade);`, labelText: 'Opacity', inputValue: behaviorFadeAmount, inputType: 'number', inputMin: 0, inputMax: 100, inputStyle: 'width: 100px !important;', notes: '%', notesStyle: 'width: 10px; margin-left: 14px;', }); parentDiv.appendChild(fadeAmountFormRow); // Blur amount const behaviorBlurAmount = parseInt(gmcGet(behaviorsMetadata.blur.fieldName)); log(DEBUG, 'behaviorBlurAmount', behaviorBlurAmount); const blurAmountFormRow = createFormRow({ formRowClass: ['update-details'], formRowStyle: `width: ${COLUMN_TWO_WIDTH}; float: right; display: var(--filterboxd-${filterName}-behavior-blur);`, labelText: 'Amount', inputValue: behaviorBlurAmount, inputType: 'number', inputMin: 1, inputStyle: 'width: 100px !important;', notes: 'px', notesStyle: 'width: 10px; margin-left: 14px;', }); parentDiv.appendChild(blurAmountFormRow); // Replace value const behaviorReplaceValue = gmcGet(behaviorsMetadata.replace.fieldName); log(DEBUG, 'behaviorReplaceValue', behaviorReplaceValue); const replaceValueFormRow = createFormRow({ formRowStyle: `width: ${COLUMN_TWO_WIDTH}; float: right; display: var(--filterboxd-${filterName}-behavior-replace);`, labelText: behaviorsMetadata.replace.labelText, inputValue: behaviorReplaceValue, inputType: 'text', }); parentDiv.appendChild(replaceValueFormRow); // Custom CSS const behaviorCustomValue = gmcGet(behaviorsMetadata.custom.fieldName); log(DEBUG, 'behaviorCustomValue', behaviorCustomValue); const cssFormRow = createFormRow({ formRowStyle: `width: ${COLUMN_TWO_WIDTH}; float: right; display: var(--filterboxd-${filterName}-behavior-custom);`, labelText: 'CSS', inputValue: behaviorCustomValue, inputType: 'text', }); parentDiv.appendChild(cssFormRow); return [ behaviorFormRow, fadeAmountFormRow, blurAmountFormRow, replaceValueFormRow, behaviorCustomValue, ]; } function buildToggleSectionListItems(filterName, unorderedList, listItemMetadata) { log(DEBUG, 'buildListItemToggles()'); const filter = getFilter(filterName); listItemMetadata.forEach(metadata => { const { type, name, description } = metadata; if (type === 'label') { const label = document.createElement('label'); unorderedList.appendChild(label); label.innerText = description; label.style.cssText = 'margin: 1em 0em;'; return; } const checked = filter[name] || false; const listItem = document.createElement('li'); listItem.classList.add('option'); const label = document.createElement('label'); listItem.appendChild(label); label.classList.add('option-label', '-toggle', 'switch-control'); const labelSpan = document.createElement('span'); label.appendChild(labelSpan); labelSpan.classList.add('label'); labelSpan.innerText = description; const labelInput = document.createElement('input'); label.appendChild(labelInput); labelInput.classList.add('checkbox'); labelInput.setAttribute('type', 'checkbox'); labelInput.setAttribute('role', 'switch'); labelInput.setAttribute('data-filter-name', filterName); labelInput.setAttribute('data-field-name', name); labelInput.checked = checked; const labelCheckboxSpan = document.createElement('span'); label.appendChild(labelCheckboxSpan); labelCheckboxSpan.classList.add('state'); const checkboxTrackSpan = document.createElement('span'); labelCheckboxSpan.appendChild(checkboxTrackSpan); checkboxTrackSpan.classList.add('track'); const checkboxHandleSpan = document.createElement('span'); checkboxTrackSpan.appendChild(checkboxHandleSpan); checkboxHandleSpan.classList.add('handle'); unorderedList.appendChild(listItem); }); } function buildUserscriptLink(userscriptListItem, unorderedList) { log(DEBUG, 'buildUserscriptLink()'); const userscriptLink = userscriptListItem.firstElementChild; userscriptListItem.onclick = (event) => { event.preventDefault(); log(DEBUG, 'userscriptListItem clicked'); log(VERBOSE, 'event', event); const link = event.target; log(VERBOSE, 'link', link); const id = parseInt(link.getAttribute('data-film-id')); const slug = link.getAttribute('data-film-slug'); const name = link.getAttribute('data-film-name'); const year = link.getAttribute('data-film-release-year'); const filmMetadata = { id, slug, name, year, }; const titleIsHidden = link.getAttribute('data-title-hidden') === 'true'; modifyThenObserve(() => { if (titleIsHidden) { removeFilterFromFilm(filmMetadata); removeFromFilmFilter(filmMetadata); } else { addFilterToFilm(filmMetadata); addToHiddenTitles(filmMetadata); } const sidebarLink = document.querySelector(createId(SELECTORS.userpanel.userscriptListItemId)); if (sidebarLink) { updateLinkInPopMenu(!titleIsHidden, sidebarLink); const popupLink = document.querySelector(`.${SELECTORS.filmPosterPopMenu.userscriptListItemClass} a`); if (popupLink) updateLinkInPopMenu(!titleIsHidden, popupLink); } else { updateLinkInPopMenu(!titleIsHidden, link); } }); }; let filmPosterSelector; let titleId = unorderedList.querySelector('[data-film-id]')?.getAttribute('data-film-id'); log(DEBUG, 'titleId', titleId); if (titleId) { filmPosterSelector = `[data-film-id='${titleId}'].film-poster`; } else { const titleName = unorderedList.querySelector('[data-film-name]')?.getAttribute('data-film-name'); log(DEBUG, 'titleName', titleName); if (titleName) { filmPosterSelector = `[data-film-name='${titleName}'].film-poster`; } else { logError('No film id or name found in unordered list'); return; } } log(DEBUG, 'filmPosterSelector', filmPosterSelector); const filmPoster = document.querySelector(filmPosterSelector); log(DEBUG, 'filmPoster', filmPoster); if (!titleId) { titleId = filmPoster?.getAttribute('data-film-id'); log(DEBUG, 'titleId', titleId); if (!titleId) { logError('No film id found on film poster'); return; } } userscriptLink.setAttribute('data-film-id', titleId); if (!filmPoster) { logError('No film poster found'); log(INFO, 'unorderedList', unorderedList); } const titleSlug = unorderedList.querySelector('[data-film-slug]')?.getAttribute('data-film-slug') || filmPoster?.getAttribute('data-film-slug'); log(DEBUG, 'titleSlug', titleSlug); if (titleSlug) userscriptLink.setAttribute('data-film-slug', titleSlug); const titleName = unorderedList.querySelector('[data-film-name]')?.getAttribute('data-film-name'); log(DEBUG, 'titleName', titleName); if (titleName) userscriptLink.setAttribute('data-film-name', titleName); // Title year isn't present in the pop menu list, so retrieve it from the film poster const titleYear = filmPoster?.querySelector('.has-menu')?.getAttribute('data-original-title')?.match(/\((\d{4})\)/)?.[1] || document.querySelector('div.releaseyear a')?.innerText || document.querySelector('small.metadata a')?.innerText || filmPoster?.querySelector('.frame-title')?.innerText?.match(/\((\d{4})\)/)?.[1]; log(DEBUG, 'titleYear', titleYear); if (titleYear) userscriptLink.setAttribute('data-film-release-year', titleYear); const filmFilter = getFilter('filmFilter'); log(DEBUG, 'filmFilter', filmFilter); const titleIsHidden = filmFilter.some( filteredFilm => filteredFilm.id?.toString() === titleId?.toString(), ); log(DEBUG, 'titleIsHidden', titleIsHidden); updateLinkInPopMenu(titleIsHidden, userscriptLink); userscriptLink.removeAttribute('class'); return userscriptListItem; } function buildToggleSection(parentElement, sectionTitle, filerName, sectionMetadata) { log(DEBUG, 'buildToggleSection()'); const formRowDiv = document.createElement('div'); parentElement.appendChild(formRowDiv); formRowDiv.style.cssText = 'margin-bottom: 40px;'; const sectionHeader = document.createElement('h3'); formRowDiv.append(sectionHeader); sectionHeader.classList.add('title-3'); sectionHeader.style.cssText = 'margin-top: 0em;'; sectionHeader.innerText = sectionTitle; const unorderedList = document.createElement('ul'); formRowDiv.append(unorderedList); unorderedList.classList.add('options-list', '-toggle-list', 'js-toggle-list'); buildToggleSectionListItems( filerName, unorderedList, sectionMetadata, ); let formColumnDiv = document.createElement('div'); formRowDiv.appendChild(formColumnDiv); formColumnDiv.classList.add('form-columns', '-cols2'); } function createFormRow({ formRowClass = [], formRowStyle = '', labelText = '', helpText = '', inputValue = '', inputType = 'text', inputMin = null, inputMax = null, inputStyle = '', selectArray = [], selectOnChange = () => {}, notes = '', notesStyle = '', }) { log(DEBUG, 'createFormRow()'); const formRow = document.createElement('div'); formRow.classList.add('form-row'); formRow.style.cssText = formRowStyle; formRow.classList.add(...formRowClass); const selectList = document.createElement('div'); formRow.appendChild(selectList); selectList.classList.add('select-list'); const label = document.createElement('label'); selectList.appendChild(label); label.classList.add('label'); label.textContent = labelText; if (helpText) { const helpIcon = document.createElement('span'); label.appendChild(helpIcon); helpIcon.classList.add('s', 'icon-14', 'icon-tip', 'tooltip'); helpIcon.setAttribute('target', '_blank'); helpIcon.setAttribute('data-html', 'true'); helpIcon.setAttribute('data-original-title', helpText); helpIcon.innerHTML = '(Help)'; } const inputDiv = document.createElement('div'); selectList.appendChild(inputDiv); inputDiv.classList.add('input'); inputDiv.style.cssText = inputStyle; if (inputType === 'select') { const select = document.createElement('select'); inputDiv.appendChild(select); select.classList.add('select'); selectArray.forEach(option => { const optionElement = document.createElement('option'); select.appendChild(optionElement); optionElement.value = option; optionElement.textContent = option; if (option === inputValue) optionElement.setAttribute('selected', 'selected'); }); select.onchange = selectOnChange; } else if (['text', 'number'].includes(inputType)) { const input = document.createElement('input'); inputDiv.appendChild(input); input.type = inputType; input.classList.add('field'); input.value = inputValue; if (inputMin !== null) input.min = inputMin; if (inputMax !== null) input.max = inputMax; } if (notes) { const notesElement = document.createElement('p'); selectList.appendChild(notesElement); notesElement.classList.add('notes'); notesElement.style.cssText = notesStyle; notesElement.textContent = notes; } return formRow; } function displaySavedBadge() { log(DEBUG, 'displaySavedBadge()'); const savedBadge = document.querySelector(`.${SELECTORS.settings.savedBadgeClass}`); savedBadge.classList.remove('hidden'); savedBadge.classList.add('fade'); setTimeout(() => { savedBadge.classList.add('fade-out'); }, 2000); setTimeout(() => { savedBadge.classList.remove('fade', 'fade-out'); savedBadge.classList.add('hidden'); }, 3000); } function getFilter(filterName) { log(DEBUG, 'getFilter()'); return JSON.parse(gmcGet(filterName)); } function getFilterBehaviorStyle(filterName) { log(DEBUG, 'getFilterBehaviorStyle()'); let behaviorStyle; let behaviorType = gmcGet(`${filterName}BehaviorType`); log(DEBUG, 'behaviorType', behaviorType); const behaviorFadeAmount = gmcGet(`${filterName}BehaviorFadeAmount`); log(VERBOSE, 'behaviorFadeAmount', behaviorFadeAmount); const behaviorBlurAmount = gmcGet(`${filterName}BehaviorBlurAmount`); log(VERBOSE, 'behaviorBlurAmount', behaviorBlurAmount); const behaviorCustomValue = gmcGet(`${filterName}BehaviorCustomValue`); log(VERBOSE, 'behaviorCustomValue', behaviorCustomValue); switch (behaviorType) { case 'Remove': behaviorStyle = 'display: none !important;'; break; case 'Fade': behaviorStyle = `opacity: ${behaviorFadeAmount}%`; break; case 'Blur': behaviorStyle = `filter: blur(${behaviorBlurAmount}px)`; break; case 'Custom': behaviorStyle = behaviorCustomValue; break; } updateBehaviorCSSVariables(filterName, behaviorType); return behaviorStyle; } function gmcInitialized() { log(DEBUG, 'gmcInitialized()'); log(QUIET, 'Running'); GMC.css.basic = ''; if (RESET_DATA) { log(QUIET, 'Resetting GMC'); for (const [key, field] of Object.entries(GMC_FIELDS)) { const value = field.default; gmcSet(key, value); } log(QUIET, 'GMC reset'); } let userscriptStyle = document.createElement('style'); userscriptStyle.setAttribute('id', 'filterboxd-style'); const filmBehaviorStyle = getFilterBehaviorStyle('film'); log(VERBOSE, 'filmBehaviorStyle', filmBehaviorStyle); const reviewBehaviorStyle = getFilterBehaviorStyle('review'); log(VERBOSE, 'reviewBehaviorStyle', reviewBehaviorStyle); userscriptStyle.textContent += ` .${SELECTORS.filter.filmClass} { ${filmBehaviorStyle} } .${SELECTORS.filter.reviewClass} { ${reviewBehaviorStyle} } .${SELECTORS.settings.filteredTitleLinkClass} { cursor: pointer; margin-right: 0.3rem !important; } .${SELECTORS.settings.filteredTitleLinkClass}:hover { background: #303840; color: #def; } .${SELECTORS.settings.removePendingClass} { outline: 1px dashed #ee7000; outline-offset: -1px; } .hidden { visibility: hidden; } .fade { opacity: 1; transition: opacity 1s ease-out; } .fade.fade-out { opacity: 0; } `; document.body.appendChild(userscriptStyle); const onSettingsPage = window.location.href.includes('/settings/'); log(VERBOSE, 'onSettingsPage', onSettingsPage); if (onSettingsPage) { maybeAddConfigurationToSettings(); } else { applyFilters(); startObserving(); } } function trySelectFilterboxdTab() { const maxAttempts = 10; let attempts = 0; let successes = 0; log(DEBUG, `Attempting to select Filterboxd tab (attempt ${attempts + 1}/${maxAttempts})`); const tabLink = document.querySelector('a[data-id="filterboxd"]'); if (!tabLink) { log(DEBUG, 'Filterboxd tab link not found yet'); if (attempts < maxAttempts) { attempts++; setTimeout(trySelectFilterboxdTab, 100); } else { logError('Failed to find Filterboxd tab after maximum attempts'); } return; } try { tabLink.click(); setTimeout(() => { const tabSelected = document.querySelector('li.selected:has(a[data-id="filterboxd"])') !== null; if (tabSelected) { log(DEBUG, 'Filterboxd tab selected successfully'); successes++; // There's a race condition between the click and the "Profile" tab being loaded and selected if (successes < 2) setTimeout(trySelectFilterboxdTab, 500); } else { log(DEBUG, 'Click didn\'t select the tab properly'); if (attempts < maxAttempts) { attempts++; setTimeout(trySelectFilterboxdTab, 100); } else { logError('Failed to select Filterboxd tab after maximum attempts'); } } }, 50); } catch (error) { logError('Error selecting Filterboxd tab', error); if (attempts < maxAttempts) { attempts++; setTimeout(trySelectFilterboxdTab, 100); } } } function maybeAddConfigurationToSettings() { log(DEBUG, 'maybeAddConfigurationToSettings()'); const userscriptTabId = 'tab-filterboxd'; const configurationExists = document.querySelector(createId(userscriptTabId)); log(VERBOSE, 'configurationExists', configurationExists); if (configurationExists) { log(DEBUG, 'Filterboxd configuration tab is present'); return; } const userscriptTabDiv = document.createElement('div'); const settingsTabbedContent = document.querySelector(SELECTORS.settings.tabbedContentId); settingsTabbedContent.appendChild(userscriptTabDiv); userscriptTabDiv.setAttribute('id', userscriptTabId); userscriptTabDiv.classList.add('tabbed-content-block'); const tabTitle = document.createElement('h2'); userscriptTabDiv.append(tabTitle); tabTitle.style.cssText = 'margin-bottom: 1em;'; tabTitle.innerText = 'Filterboxd'; const tabPrimaryColumn = document.createElement('div'); userscriptTabDiv.append(tabPrimaryColumn); tabPrimaryColumn.classList.add('col-10', 'overflow'); const asideColumn = document.createElement('aside'); userscriptTabDiv.append(asideColumn); asideColumn.classList.add('col-12', 'overflow', 'col-right', 'js-hide-in-app'); // Filter film page const filmPageFilterMetadata = [ { type: 'toggle', name: 'backdropImage', description: 'Remove backdrop image', }, { type: 'label', description: 'Left column', }, { type: 'toggle', name: 'poster', description: 'Remove poster', }, { type: 'toggle', name: 'stats', description: 'Remove Letterboxd stats', }, { type: 'toggle', name: 'whereToWatch', description: 'Remove "Where to watch" section', }, { type: 'label', description: 'Right column', }, { type: 'toggle', name: 'userActionsPanel', description: 'Remove user actions panel', }, { type: 'toggle', name: 'ratings', description: 'Remove "Ratings" section', }, { type: 'label', description: 'Middle column', }, { type: 'toggle', name: 'releaseYear', description: 'Remove release year text', }, { type: 'toggle', name: 'director', description: 'Remove director text', }, { type: 'toggle', name: 'tagline', description: 'Remove tagline text', }, { type: 'toggle', name: 'description', description: 'Remove description text', }, { type: 'toggle', name: 'castTab', description: 'Remove "Cast" tab', }, { type: 'toggle', name: 'crewTab', description: 'Remove "Crew" tab', }, { type: 'toggle', name: 'detailsTab', description: 'Remove "Details" tab', }, { type: 'toggle', name: 'genresTab', description: 'Remove "Genres" tab', }, { type: 'toggle', name: 'releasesTab', description: 'Remove "Releases" tab', }, { type: 'toggle', name: 'activityFromFriends', description: 'Remove "Activity from friends" section', }, { type: 'toggle', name: 'filmNews', description: 'Remove HQ film news section', }, { type: 'toggle', name: 'reviewsFromFriends', description: 'Remove "Reviews from friends" section', }, { type: 'toggle', name: 'popularReviews', description: 'Remove "Popular reviews" section', }, { type: 'toggle', name: 'recentReviews', description: 'Remove "Recent reviews" section', }, { type: 'toggle', name: 'relatedFilms', description: 'Remove "Related films" section', }, { type: 'toggle', name: 'similarFilms', description: 'Remove "Similar films" section', }, { type: 'toggle', name: 'mentionedBy', description: 'Remove "Mentioned by" section', }, { type: 'toggle', name: 'popularLists', description: 'Remove "Popular lists" section', }, ]; buildToggleSection( asideColumn, 'Film Page Filter', 'filmPageFilter', filmPageFilterMetadata, ); // Advanced Options const formRowDiv = document.createElement('div'); asideColumn.appendChild(formRowDiv); formRowDiv.style.cssText = 'margin-bottom: 40px;'; const sectionHeader = document.createElement('h3'); formRowDiv.append(sectionHeader); sectionHeader.classList.add('title-3'); sectionHeader.style.cssText = 'margin-top: 0em;'; sectionHeader.innerText = 'Advanced Options'; const logLevelValue = gmcGet('logLevel'); log(DEBUG, 'logLevelValue', logLevelValue); const logLevelFormRow = createFormRow({ formRowClass: ['update-details'], formRowStyle: `width: ${COLUMN_ONE_WIDTH};`, labelText: 'Log level ', helpText: 'Determines how much logging
is visible in the browser console', inputValue: logLevelValue, inputType: 'select', selectArray: LOG_LEVELS.options, }); formRowDiv.appendChild(logLevelFormRow); const mutationsDiv = document.createElement('div'); mutationsDiv.style.cssText = 'display: flex; align-items: center;'; formRowDiv.append(mutationsDiv); const maxActiveMutationsValue = gmcGet('maxActiveMutations'); log(DEBUG, 'maxActiveMutationsValue', maxActiveMutationsValue); const maxActiveMutationsFormRow = createFormRow({ formRowClass: ['update-details'], formRowStyle: `width: ${COLUMN_HALF_WIDTH};`, labelText: 'Max active mutations ', helpText: 'Safety limit that halts execution
when a certain number of modifications
are performed by the script', inputValue: maxActiveMutationsValue, inputType: 'number', inputMin: 1, inputStyle: 'width: 100px !important;', }); mutationsDiv.appendChild(maxActiveMutationsFormRow); const maxIdleMutationsValue = gmcGet('maxIdleMutations'); log(DEBUG, 'maxIdleMutationsValue', maxIdleMutationsValue); const maxIdleMutationsFormRow = createFormRow({ formRowClass: ['update-details'], formRowStyle: `width: ${COLUMN_HALF_WIDTH}; float: right;`, labelText: 'Max idle mutations ', helpText: 'Safety limit that halts execution
when a certain number of modifications
are performed by Letterboxd
that did not result in modifications
from the script', inputValue: maxIdleMutationsValue, inputType: 'number', inputMin: 1, inputStyle: 'width: 100px !important;', }); mutationsDiv.appendChild(maxIdleMutationsFormRow); let formColumnDiv = document.createElement('div'); formRowDiv.appendChild(formColumnDiv); formColumnDiv.classList.add('form-columns', '-cols2'); // Filter films const favoriteFilmsDiv = document.querySelector(SELECTORS.settings.favoriteFilms); const filteredFilmsDiv = favoriteFilmsDiv.cloneNode(true); tabPrimaryColumn.appendChild(filteredFilmsDiv); filteredFilmsDiv.style.cssText = 'margin-bottom: 20px;'; const posterList = filteredFilmsDiv.querySelector(SELECTORS.settings.posterList); posterList.remove(); filteredFilmsDiv.querySelector(SELECTORS.settings.subtitle).innerText = 'Films Filter'; filteredFilmsDiv.querySelector(SELECTORS.settings.note).innerText = 'Right click to mark for removal.'; let hiddenTitlesDiv = document.createElement('div'); filteredFilmsDiv.append(hiddenTitlesDiv); const hiddenTitlesParagraph = document.createElement('p'); hiddenTitlesDiv.appendChild(hiddenTitlesParagraph); hiddenTitlesDiv.classList.add('text-sluglist'); const filmFilter = getFilter('filmFilter'); log(VERBOSE, 'filmFilter', filmFilter); filmFilter.forEach((filteredFilm, index) => { log(VERBOSE, 'filteredFilm', filteredFilm); let filteredTitleLink = document.createElement('a'); hiddenTitlesParagraph.appendChild(filteredTitleLink); if (filteredFilm.slug) filteredTitleLink.href= `/film/${filteredFilm.slug}`; filteredTitleLink.classList.add( 'text-slug', SELECTORS.processedClass.apply, SELECTORS.settings.filteredTitleLinkClass, ); filteredTitleLink.setAttribute('data-film-id', filteredFilm.id); filteredTitleLink.setAttribute('index', index); let titleLinkText = filteredFilm.name; if (['', null, undefined].includes(filteredFilm.name)) { log(INFO, 'filteredFilm has no name; marking as broken', filteredFilm); titleLinkText = 'Broken, please remove'; } if (!['', null, undefined].includes(filteredFilm.year)) { titleLinkText += ` (${filteredFilm.year})`; } filteredTitleLink.innerText = titleLinkText; filteredTitleLink.oncontextmenu = (event) => { event.preventDefault(); filteredTitleLink.classList.toggle(SELECTORS.settings.removePendingClass); }; }); let formColumnsDiv = document.createElement('div'); filteredFilmsDiv.appendChild(formColumnsDiv); formColumnsDiv.classList.add('form-columns', '-cols2'); // Filter films behavior const filmBehaviorsMetadata = { fade: { fieldName: 'filmBehaviorFadeAmount', }, blur: { fieldName: 'filmBehaviorBlurAmount', }, replace: { fieldName: 'filmBehaviorReplaceValue', labelText: 'Direct image URL', }, custom: { fieldName: 'filmBehaviorCustomValue', }, }; const filmFormRows = buildBehaviorFormRows( formColumnsDiv, 'film', FILM_BEHAVIORS, filmBehaviorsMetadata, ); const clearDiv = filteredFilmsDiv.querySelector(SELECTORS.settings.clear); clearDiv.remove(); // Filter reviews const filteredReviewsFormRow = document.createElement('div'); tabPrimaryColumn.append(filteredReviewsFormRow); filteredReviewsFormRow.classList.add('form-row'); const filteredReviewsTitle = document.createElement('h3'); filteredReviewsFormRow.append(filteredReviewsTitle); filteredReviewsTitle.classList.add('title-3'); filteredReviewsTitle.style.cssText = 'margin-top: 0em;'; filteredReviewsTitle.innerText = 'Reviews Filter'; // First unordered list const filteredReviewsUnorderedListFirst = document.createElement('ul'); filteredReviewsFormRow.append(filteredReviewsUnorderedListFirst); filteredReviewsUnorderedListFirst.classList.add('options-list', '-toggle-list', 'js-toggle-list'); filteredReviewsUnorderedListFirst.style.cssText += 'margin-bottom: 5px;'; const reviewFilterItemsFirst = [ { name: 'ratings', description: 'Remove ratings from reviews', }, { name: 'likes', description: 'Remove likes from reviews', }, { name: 'comments', description: 'Remove comments from reviews', }, { name: 'byWordCount', description: 'Filter reviews by minimum word count', }, ]; buildToggleSectionListItems( 'reviewFilter', filteredReviewsUnorderedListFirst, reviewFilterItemsFirst, ); // Minium word count let minimumWordCountDiv = document.createElement('div'); filteredReviewsFormRow.appendChild(minimumWordCountDiv); minimumWordCountDiv.classList.add('form-columns', '-cols2'); const minimumWordCountValue = gmcGet('reviewMinimumWordCount'); log(DEBUG, 'minimumWordCountValue', minimumWordCountValue); const minimumWordCountFormRow = createFormRow({ formRowClass: ['update-details'], formRowStyle: `width: ${COLUMN_TWO_WIDTH}; float: right; margin-bottom: 10px;`, inputValue: minimumWordCountValue, inputType: 'number', inputStyle: 'width: 100px !important;', notes: 'words', notesStyle: 'width: 10px; margin-left: 14px;', }); minimumWordCountDiv.appendChild(minimumWordCountFormRow); // Second unordered list const filteredReviewsUnorderedListSecond = document.createElement('ul'); filteredReviewsFormRow.append(filteredReviewsUnorderedListSecond); filteredReviewsUnorderedListSecond.classList.add('options-list', '-toggle-list', 'js-toggle-list'); filteredReviewsUnorderedListSecond.style.cssText += 'margin: 0 0 1.53846154rem;'; const reviewFilterItemsSecond = [ { name: 'withSpoilers', description: 'Filter reviews that contain spoilers', }, { name: 'withoutRatings', description: 'Filter reviews that don\'t have ratings', }, ]; buildToggleSectionListItems( 'reviewFilter', filteredReviewsUnorderedListSecond, reviewFilterItemsSecond, ); let reviewColumnsDiv = document.createElement('div'); filteredReviewsFormRow.appendChild(reviewColumnsDiv); reviewColumnsDiv.classList.add('form-columns', '-cols2'); const reviewBehaviorsMetadata = { fade: { fieldName: 'reviewBehaviorFadeAmount', }, blur: { fieldName: 'reviewBehaviorBlurAmount', }, replace: { fieldName: 'reviewBehaviorReplaceValue', labelText: 'Text', }, custom: { fieldName: 'reviewBehaviorCustomValue', }, }; const reviewFormRows = buildBehaviorFormRows( reviewColumnsDiv, 'review', REVIEW_BEHAVIORS, reviewBehaviorsMetadata, ); // Filter homepage const homepageFilterMetadata = [ { name: 'friendsHaveBeenWatching', description: 'Remove "Here\'s what your friends have been watching..." title text', }, { name: 'newFromFriends', description: 'Remove "New from friends" films section', }, { name: 'popularWithFriends', description: 'Remove "Popular with friends" section', }, { name: 'discoveryStream', description: 'Remove discovery section (e.g. festivals, competitions)', }, { name: 'latestNews', description: 'Remove "Latest news" section', }, { name: 'popularReviewsWithFriends', description: 'Remove "Popular reviews with friends" section', }, { name: 'newListsFromFriends', description: 'Remove "New from friends" lists section', }, { name: 'popularLists', description: 'Remove "Popular lists" section', }, { name: 'recentStories', description: 'Remove "Recent stories" section', }, { name: 'recentShowdowns', description: 'Remove "Recent showdowns" section', }, { name: 'recentNews', description: 'Remove "Recent news" section', }, ]; buildToggleSection( tabPrimaryColumn, 'Homepage Filter', 'homepageFilter', homepageFilterMetadata, ); // Save changes let buttonsRowDiv = document.createElement('div'); userscriptTabDiv.appendChild(buttonsRowDiv); buttonsRowDiv.style.cssText = 'display: flex; align-items: center;'; buttonsRowDiv.classList.add('buttons', 'clear', 'row'); let saveInput = document.createElement('input'); buttonsRowDiv.appendChild(saveInput); saveInput.classList.add('button', 'button-action'); saveInput.setAttribute('value', 'Save Changes'); saveInput.setAttribute('type', 'submit'); saveInput.onclick = (event) => { event.preventDefault(); const pendingRemovals = hiddenTitlesParagraph.querySelectorAll(`.${SELECTORS.settings.removePendingClass}`); pendingRemovals.forEach(removalLink => { const id = parseInt(removalLink.getAttribute('data-film-id')); const filteredFilm = filmFilter.find(filteredFilm => filteredFilm.id === id); if (filteredFilm) { removeFilterFromFilm(filteredFilm); removeFromFilmFilter(filteredFilm); } else { const index = removalLink.getAttribute('index'); removeFromFilmFilter(null, index); } removalLink.remove(); }); const minimumWordCountValue = parseInt(minimumWordCountFormRow.querySelector('input').value || 0); log(DEBUG, 'minimumWordCountValue', minimumWordCountValue); gmcSet('reviewMinimumWordCount', minimumWordCountValue); saveBehaviorSettings('film', filmFormRows); saveBehaviorSettings('review', reviewFormRows); const inputToggles = userscriptTabDiv.querySelectorAll('input[type="checkbox"]'); inputToggles.forEach(inputToggle => { const filterName = inputToggle.getAttribute('data-filter-name'); const filter = getFilter(filterName); const fieldName = inputToggle.getAttribute('data-field-name'); const checked = inputToggle.checked; filter[fieldName] = checked; setFilter(filterName, filter); }); const logLevel = logLevelFormRow.querySelector('select').value; gmcSet('logLevel', logLevel); const maxIdleMutationsValue = parseInt(maxIdleMutationsFormRow.querySelector('input').value || 0); log(DEBUG, 'maxIdleMutationsValue', maxIdleMutationsValue); gmcSet('maxIdleMutations', maxIdleMutationsValue); const maxActiveMutationsValue = parseInt(maxActiveMutationsFormRow.querySelector('input').value || 0); log(DEBUG, 'maxActiveMutationsValue', maxActiveMutationsValue); gmcSet('maxActiveMutations', maxActiveMutationsValue); gmcSave(); displaySavedBadge(); }; let checkContainerDiv = document.createElement('div'); buttonsRowDiv.appendChild(checkContainerDiv); checkContainerDiv.classList.add('check-container'); checkContainerDiv.style.cssText = 'margin-left: 10px;'; let usernameAvailableParagraph = document.createElement('p'); checkContainerDiv.appendChild(usernameAvailableParagraph); usernameAvailableParagraph.classList.add( 'username-available', 'has-icon', 'hidden', SELECTORS.settings.savedBadgeClass, ); usernameAvailableParagraph.style.cssText = 'float: left;'; let iconSpan = document.createElement('span'); usernameAvailableParagraph.appendChild(iconSpan); iconSpan.classList.add('icon'); const savedText = document.createTextNode('Saved'); usernameAvailableParagraph.appendChild(savedText); const settingsSubNav = document.querySelector(SELECTORS.settings.subNav); const userscriptSubNabListItem = document.createElement('li'); settingsSubNav.appendChild(userscriptSubNabListItem); const userscriptSubNabLink = document.createElement('a'); userscriptSubNabListItem.appendChild(userscriptSubNabLink); const userscriptSettingsLink = '/settings/?filterboxd'; userscriptSubNabLink.setAttribute('href', userscriptSettingsLink); userscriptSubNabLink.setAttribute('data-id', 'filterboxd'); userscriptSubNabLink.innerText = 'Filterboxd'; userscriptSubNabLink.onclick = (event) => { event.preventDefault(); Array.from(settingsSubNav.children).forEach(listItem => { const link = listItem.querySelector('a'); if (link.getAttribute('data-id') === 'filterboxd') { listItem.classList.add('selected'); } else { listItem.classList.remove('selected'); } }); Array.from(settingsTabbedContent.children).forEach(tab => { if (!tab.id) return; const display = tab.id === userscriptTabId ? 'block' : 'none'; tab.style.cssText = `display: ${display};`; }); window.history.replaceState(null, '', `https://letterboxd.com${userscriptSettingsLink}`); }; Array.from(settingsSubNav.children).forEach(listItem => { listItem.onclick = (event) => { const link = event.target; if (link.getAttribute('href') === userscriptSettingsLink) return; userscriptSubNabListItem.classList.remove('selected'); userscriptTabDiv.style.display = 'none'; }; }); } function maybeAddListItemToSidebar() { log(DEBUG, 'maybeAddListItemToSidebar()'); const isListPage = document.querySelector('body.list-page'); if (isListPage) return; const userscriptListItemFound = document.querySelector(createId(SELECTORS.userpanel.userscriptListItemId)); if (userscriptListItemFound) { log(DEBUG, 'Userscript list item already exists'); return false; } const userpanel = document.querySelector(SELECTORS.userpanel.self); if (!userpanel) { log(INFO, 'Userpanel not found'); return false; } const secondLastListItem = userpanel.querySelector('li:nth-last-child(3)'); if (!secondLastListItem ) { log(INFO, 'Second last list item not found'); return false; } if (secondLastListItem.classList.contains('loading-csi')) { log(INFO, 'Second last list item is loading'); return false; } let userscriptListItem = document.createElement('li'); const userscriptListLink = document.createElement('a'); userscriptListItem.appendChild(userscriptListLink); userscriptListLink.href = '#'; userscriptListItem.setAttribute('id', SELECTORS.userpanel.userscriptListItemId); const unorderedList = userpanel.querySelector('ul'); userscriptListItem = buildUserscriptLink(userscriptListItem, unorderedList); // Text: "Go PATRON to change images" const upsellLink = userpanel.querySelector('[href="/pro/"]'); // If the upsell link is present, insert above // Otherwise, inset above "Share" const insertBeforeElementIndex = upsellLink ? 2 : 1; const insertBeforeElement = userpanel.querySelector(`li:nth-last-of-type(${insertBeforeElementIndex})`); secondLastListItem.parentNode.insertBefore(userscriptListItem, insertBeforeElement); return true; } function removeFilterFromElement(element, levelsUp = 0) { log(DEBUG, 'removeFilterFromElement()'); const replaceBehavior = gmcGet('filmBehaviorType') === 'Replace poster'; log(VERBOSE, 'replaceBehavior', replaceBehavior); if (replaceBehavior) { const originalImgSrc = element.getAttribute('data-original-img-src'); if (!originalImgSrc) { log(DEBUG, 'data-original-img-src attribute not found', element); return; } element.querySelector('img').src = originalImgSrc; element.querySelector('img').srcset = originalImgSrc; element.removeAttribute('data-original-img-src'); element.classList.add(SELECTORS.processedClass.remove); element.classList.remove(SELECTORS.processedClass.apply); } else { let target = element; for (let i = 0; i < levelsUp; i++) { if (target.parentNode) { target = target.parentNode; } else { break; } } log(VERBOSE, 'target', target); target.classList.remove(SELECTORS.filter.filmClass); element.classList.add(SELECTORS.processedClass.remove); element.classList.remove(SELECTORS.processedClass.apply); } } function removeFromFilmFilter(filmMetadata, index) { log(DEBUG, 'removeFromFilmFilter()'); let filmFilter = getFilter('filmFilter'); if (filmMetadata) { filmFilter = filmFilter.filter(filteredFilm => filteredFilm.id !== filmMetadata.id); } else { filmFilter.splice(index, 1); } setFilter('filmFilter', filmFilter); } function removeFilterFromFilm({ id, slug }) { log(DEBUG, 'removeFilterFromFilm()'); const idMatch = `[data-film-id="${id}"]`; let removedSelector = `.${SELECTORS.processedClass.remove}`; log(VERBOSE, 'Activity page reviews'); document.querySelectorAll(`section.activity-row ${idMatch}`).forEach(posterElement => { removeFilterFromElement(posterElement, 3); }); log(VERBOSE, 'Activity page likes'); document.querySelectorAll(`section.activity-row .activity-summary a[href*="${slug}"]:not(${removedSelector})`).forEach(posterElement => { removeFilterFromElement(posterElement, 3); }); log(VERBOSE, 'New from friends'); document.querySelectorAll(`.poster-container ${idMatch}:not(${removedSelector})`).forEach(posterElement => { removeFilterFromElement(posterElement, 1); }); log(VERBOSE, 'Reviews'); document.querySelectorAll(`.review-tile ${idMatch}:not(${removedSelector})`).forEach(posterElement => { removeFilterFromElement(posterElement, 3); }); log(VERBOSE, 'Diary'); document.querySelectorAll(`.td-film-details [data-original-img-src]${idMatch}:not(${removedSelector})`).forEach(posterElement => { removeFilterFromElement(posterElement, 2); }); log(VERBOSE, 'Popular with friends, competitions'); const remainingElements = document.querySelectorAll( `div:not(.popmenu):not(.actions-panel) ${idMatch}:not(aside [data-film-id="${id}"]):not(#backdrop):not(${removedSelector})`, ); remainingElements.forEach(posterElement => { removeFilterFromElement(posterElement, 0); }); } function saveBehaviorSettings(filterName, formRows) { log(DEBUG, 'saveBehaviorSettings()'); const behaviorType = formRows[0].querySelector('select').value; log(DEBUG, 'behaviorType', behaviorType); gmcSet(`${filterName}BehaviorType`, behaviorType); updateBehaviorCSSVariables(filterName, behaviorType); if (behaviorType === 'Fade') { const behaviorFadeAmount = formRows[1].querySelector('input').value; log(DEBUG, 'behaviorFadeAmount', behaviorFadeAmount); gmcSet(`${filterName}BehaviorFadeAmount`, behaviorFadeAmount); } else if (behaviorType === 'Blur') { const behaviorBlurAmount = formRows[2].querySelector('input').value; log(DEBUG, 'behaviorBlurAmount', behaviorBlurAmount); gmcSet(`${filterName}BehaviorBlurAmount`, behaviorBlurAmount); } else if (behaviorType.includes('Replace')) { const behaviorReplaceValue = formRows[3].querySelector('input').value; log(DEBUG, 'behaviorReplaceValue', behaviorReplaceValue); gmcSet(`${filterName}BehaviorReplaceValue`, behaviorReplaceValue); } else if (behaviorType === 'Custom') { const behaviorCustomValue = formRows[4].querySelector('input').value; log(DEBUG, 'behaviorCustomValue', behaviorCustomValue); gmcSet(`${filterName}BehaviorCustomValue`, behaviorCustomValue); } } function setFilter(filterName, filterValue) { log(DEBUG, 'setFilter()'); gmcSet(filterName, JSON.stringify(filterValue)); return gmcSave(); } function updateBehaviorCSSVariables(filterName, behaviorType) { log(DEBUG, 'updateBehaviorTypeVariable()'); log(DEBUG, 'behaviorType', behaviorType); const fadeValue = behaviorType === 'Fade' ? 'block' : 'none'; document.documentElement.style.setProperty( `--filterboxd-${filterName}-behavior-fade`, fadeValue, ); const blurValue = behaviorType === 'Blur' ? 'block' : 'none'; document.documentElement.style.setProperty( `--filterboxd-${filterName}-behavior-blur`, blurValue, ); const replaceValue = behaviorType.includes('Replace') ? 'block' : 'none'; document.documentElement.style.setProperty( `--filterboxd-${filterName}-behavior-replace`, replaceValue, ); const customValue = behaviorType === 'Custom' ? 'block' : 'none'; document.documentElement.style.setProperty( `--filterboxd-${filterName}-behavior-custom`, customValue, ); } function updateLinkInPopMenu(titleIsHidden, link) { log(DEBUG, 'updateLinkInPopMenu()'); link.setAttribute('data-title-hidden', titleIsHidden); const innerText = titleIsHidden ? 'Remove from filter' : 'Add to filter'; link.innerText = innerText; } const urlParams = new URLSearchParams(window.location.search); const tabSelected = urlParams.get('filterboxd') !== null; log(DEBUG, 'tabSelected', tabSelected); if (tabSelected) trySelectFilterboxdTab(); let OBSERVER = new MutationObserver(observeAndModify); const GMC_FIELDS = { filmBehaviorType: { type: 'select', options: FILM_BEHAVIORS, default: 'Fade', }, filmBehaviorBlurAmount: { type: 'int', default: 3, }, filmBehaviorCustomValue: { type: 'text', default: '', }, filmBehaviorFadeAmount: { type: 'int', default: 10, }, filmBehaviorReplaceValue: { type: 'text', default: 'https://raw.githubusercontent.com/blakegearin/filterboxd/main/img/bee-movie.jpg', }, filmFilter: { type: 'text', default: JSON.stringify([]), }, filmPageFilter: { type: 'text', default: JSON.stringify({}), }, homepageFilter: { type: 'text', default: JSON.stringify({}), }, logLevel: { type: 'select', options: LOG_LEVELS.options, default: LOG_LEVELS.default, }, reviewBehaviorType: { type: 'select', options: REVIEW_BEHAVIORS, default: 'Fade', }, reviewBehaviorBlurAmount: { type: 'int', default: 3, }, reviewBehaviorCustomValue: { type: 'text', default: '', }, reviewBehaviorFadeAmount: { type: 'int', default: 10, }, reviewBehaviorReplaceValue: { type: 'text', default: 'According to all known laws of aviation, there is no way a bee should be able to fly.', }, reviewFilter: { type: 'text', default: JSON.stringify({}), }, reviewMinimumWordCount: { type: 'int', default: 10, }, maxIdleMutations: { type: 'int', default: 10000, }, maxActiveMutations: { type: 'int', default: 10000, }, }; GMC = new GM_config({ id: 'gmc-frame', events: { init: gmcInitialized, }, fields: GMC_FIELDS, }); })();