Filterboxd

Filter content on Letterboxd

  1. // ==UserScript==
  2. // @name Filterboxd
  3. // @namespace https://github.com/blakegearin/filterboxd
  4. // @version 1.5.0
  5. // @description Filter content on Letterboxd
  6. // @author Blake Gearin <hello@blakeg.me> (https://blakegearin.com)
  7. // @match https://letterboxd.com/*
  8. // @require https://openuserjs.org/src/libs/sizzle/GM_config.js
  9. // @grant GM.getValue
  10. // @grant GM.setValue
  11. // @icon https://raw.githubusercontent.com/blakegearin/filterboxd/main/img/logo.png
  12. // @supportURL https://github.com/blakegearin/filterboxd/issues
  13. // @license MIT
  14. // @copyright 2024–2025, Blake Gearin (https://blakegearin.com)
  15. // ==/UserScript==
  16.  
  17. /* jshint esversion: 6 */
  18. /* global GM_config */
  19.  
  20. (function() {
  21. 'use strict';
  22.  
  23. const VERSION = '1.5.0';
  24. const USERSCRIPT_NAME = 'Filterboxd';
  25. let GMC = null;
  26.  
  27. // Log levels
  28. const SILENT = 0;
  29. const QUIET = 1;
  30. const INFO = 2;
  31. const DEBUG = 3;
  32. const VERBOSE = 4;
  33. const TRACE = 5;
  34.  
  35. // Change to true if you want to clear all your local data; this is irreversible
  36. const RESET_DATA = false;
  37.  
  38. const LOG_LEVELS = {
  39. default: 'quiet',
  40. options: [
  41. 'silent',
  42. 'quiet',
  43. 'info',
  44. 'debug',
  45. 'verbose',
  46. 'trace',
  47. ],
  48. getName: (level) => {
  49. return {
  50. 0: 'silent',
  51. 1: 'quiet',
  52. 2: 'info',
  53. 3: 'debug',
  54. 4: 'verbose',
  55. 5: 'trace',
  56. }[level];
  57. },
  58. getValue: (name) => {
  59. return {
  60. silent: SILENT,
  61. quiet: QUIET,
  62. info: INFO,
  63. debug: DEBUG,
  64. verbose: VERBOSE,
  65. trace: TRACE,
  66. }[name];
  67. },
  68. };
  69.  
  70. function currentLogLevel() {
  71. if (GMC === null) return LOG_LEVELS.getValue(LOG_LEVELS.default);
  72.  
  73. return LOG_LEVELS.getValue(GMC.get('logLevel'));
  74. }
  75.  
  76. function log (level, message, variable = undefined) {
  77. if (currentLogLevel() < level) return;
  78.  
  79. const levelName = LOG_LEVELS.getName(level);
  80.  
  81. const log = `[${VERSION}] [${levelName}] ${USERSCRIPT_NAME}: ${message}`;
  82.  
  83. console.groupCollapsed(log);
  84.  
  85. if (variable !== undefined) console.dir(variable, { depth: null });
  86.  
  87. console.trace();
  88. console.groupEnd();
  89. }
  90.  
  91. function logError (message, error = undefined) {
  92. const log = `[${VERSION}] [error] ${USERSCRIPT_NAME}: ${message}`;
  93.  
  94. console.groupCollapsed(log);
  95.  
  96. if (error !== undefined) console.error(error);
  97.  
  98. console.trace();
  99. console.groupEnd();
  100. }
  101.  
  102. log(TRACE, 'Starting');
  103.  
  104. function gmcGet(key) {
  105. log(DEBUG, 'gmcGet()');
  106.  
  107. try {
  108. return GMC.get(key);
  109. } catch (error) {
  110. logError(`Error setting GMC, key=${key}`, error);
  111. }
  112. }
  113.  
  114. function gmcSet(key, value) {
  115. log(DEBUG, 'gmcSet()');
  116.  
  117. try {
  118. return GMC.set(key, value);
  119. } catch (error) {
  120. logError(`Error setting GMC, key=${key}, value=${value}`, error);
  121. }
  122. }
  123.  
  124. function gmcSave() {
  125. log(DEBUG, 'gmcSave()');
  126.  
  127. try {
  128. return GMC.save();
  129. } catch (error) {
  130. logError('Error saving GMC', error);
  131. }
  132. }
  133.  
  134. function startObserving() {
  135. log(DEBUG, 'startObserving()');
  136.  
  137. OBSERVER.observe(
  138. document.body,
  139. {
  140. childList: true,
  141. subtree: true,
  142. },
  143. );
  144. }
  145.  
  146. function modifyThenObserve(callback) {
  147. log(DEBUG, 'modifyThenObserve()');
  148.  
  149. OBSERVER.disconnect();
  150. callback();
  151. startObserving();
  152. }
  153.  
  154. function mutationsExceedsLimits() {
  155. // Fail-safes to prevent infinite loops
  156. if (IDLE_MUTATION_COUNT > gmcGet('maxIdleMutations')) {
  157. logError('Max idle mutations exceeded');
  158. OBSERVER.disconnect();
  159.  
  160. return true;
  161. } else if (ACTIVE_MUTATION_COUNT >= gmcGet('maxActiveMutations')) {
  162. logError('Max active mutations exceeded');
  163. OBSERVER.disconnect();
  164.  
  165. return true;
  166. }
  167.  
  168. return false;
  169. }
  170.  
  171. function observeAndModify(mutationsList) {
  172. log(VERBOSE, 'observeAndModify()');
  173.  
  174. if (mutationsExceedsLimits()) return;
  175.  
  176. log(VERBOSE, 'mutationsList.length', mutationsList.length);
  177.  
  178. for (const mutation of mutationsList) {
  179. if (mutation.type !== 'childList') return;
  180.  
  181. log(TRACE, 'mutation', mutation);
  182.  
  183. let sidebarUpdated;
  184. let popMenuUpdated;
  185. let filtersApplied;
  186.  
  187. modifyThenObserve(() => {
  188. sidebarUpdated = maybeAddListItemToSidebar();
  189. log(VERBOSE, 'sidebarUpdated', sidebarUpdated);
  190.  
  191. popMenuUpdated = addListItemToPopMenu();
  192. log(VERBOSE, 'popMenuUpdated', popMenuUpdated);
  193.  
  194. filtersApplied = applyFilters();
  195. log(VERBOSE, 'filtersApplied', filtersApplied);
  196. });
  197.  
  198. const activeMutation = sidebarUpdated || popMenuUpdated || filtersApplied;
  199. log(DEBUG, 'activeMutation', activeMutation);
  200.  
  201. if (activeMutation) {
  202. ACTIVE_MUTATION_COUNT++;
  203. log(VERBOSE, 'ACTIVE_MUTATION_COUNT', ACTIVE_MUTATION_COUNT);
  204. } else {
  205. IDLE_MUTATION_COUNT++;
  206. log(VERBOSE, 'IDLE_MUTATION_COUNT', IDLE_MUTATION_COUNT);
  207. }
  208.  
  209. if (mutationsExceedsLimits()) break;
  210. }
  211. }
  212.  
  213. // Source: https://stackoverflow.com/a/21144505/5988852
  214. function countWords(string) {
  215. var matches = string.match(/[\w\d’'-]+/gi);
  216. return matches ? matches.length : 0;
  217. }
  218.  
  219. function createId(string) {
  220. log(TRACE, 'createId()');
  221.  
  222. if (string.startsWith('#')) return string;
  223.  
  224. if (string.startsWith('.')) {
  225. logError(`Attempted to create an id from a class: "${string}"`);
  226. return;
  227. }
  228.  
  229. if (string.startsWith('[')) {
  230. logError(`Attempted to create an id from an attribute selector: "${string}"`);
  231. return;
  232. }
  233.  
  234. return `#${string}`;
  235. }
  236.  
  237. const FILM_BEHAVIORS = [
  238. 'Remove',
  239. 'Fade',
  240. 'Blur',
  241. 'Replace poster',
  242. 'Custom',
  243. ];
  244. const REVIEW_BEHAVIORS = [
  245. 'Remove',
  246. 'Fade',
  247. 'Blur',
  248. 'Replace text',
  249. 'Custom',
  250. ];
  251. const COLUMN_ONE_WIDTH = '33%';
  252. const COLUMN_TWO_WIDTH = '64.8%';
  253. const COLUMN_HALF_WIDTH = '50%';
  254.  
  255. let IDLE_MUTATION_COUNT = 0;
  256. let ACTIVE_MUTATION_COUNT = 0;
  257. let SELECTORS = {
  258. filmPosterPopMenu: {
  259. self: '.film-poster-popmenu',
  260. userscriptListItemClass: 'filterboxd-list-item',
  261. addToList: '.film-poster-popmenu .menu-item-add-to-list',
  262. addThisFilm: '.film-poster-popmenu .menu-item-add-this-film',
  263. },
  264. filmPageSections: {
  265. backdropImage: 'body.backdrop-loaded .backdrop-container',
  266. // Left column
  267. poster: '#film-page-wrapper section.poster-list a[data-js-trigger="postermodal"]',
  268. stats: '#film-page-wrapper section.poster-list ul.film-stats',
  269. whereToWatch: '#film-page-wrapper section.watch-panel',
  270. // Right column
  271. userActionsPanel: '#film-page-wrapper section#userpanel',
  272. ratings: '#film-page-wrapper section.ratings-histogram-chart',
  273. // Middle column
  274. releaseYear: '#film-page-wrapper .details .releaseyear',
  275. director: '#film-page-wrapper .details .credits',
  276. tagline: '#film-page-wrapper .tagline',
  277. description: '#film-page-wrapper .truncate',
  278. castTab: '#film-page-wrapper #tabbed-content ul li:nth-of-type(1),#film-page-wrapper #tab-cast',
  279. crewTab: '#film-page-wrapper #tabbed-content ul li:nth-of-type(2),#film-page-wrapper #tab-crew',
  280. detailsTab: '#film-page-wrapper #tabbed-content ul li:nth-of-type(3),#film-page-wrapper #tab-details',
  281. genresTab: '#film-page-wrapper #tabbed-content ul li:nth-of-type(4),#film-page-wrapper #tab-genres',
  282. releasesTab: '#film-page-wrapper #tabbed-content ul li:nth-of-type(5),#film-page-wrapper #tab-releases',
  283. activityFromFriends: '#film-page-wrapper section.activity-from-friends',
  284. filmNews: '#film-page-wrapper section.film-news',
  285. reviewsFromFriends: '#film-page-wrapper section#popular-reviews-with-friends',
  286. popularReviews: '#film-page-wrapper section#popular-reviews',
  287. recentReviews: '#film-page-wrapper section#recent-reviews',
  288. relatedFilms: '#film-page-wrapper section#related',
  289. similarFilms: '#film-page-wrapper section.related-films:not(#related)',
  290. mentionedBy: '#film-page-wrapper section#film-hq-mentions',
  291. popularLists: '#film-page-wrapper section:has(#film-popular-lists)',
  292. },
  293. filter: {
  294. filmClass: 'filterboxd-filter-film',
  295. reviewClass: 'filterboxd-filter-review',
  296. reviews: {
  297. ratings: '.film-detail .attribution .rating,.film-detail-meta .rating,.activity-summary .rating,.film-metadata .rating,.-rated .rating,.poster-viewingdata .rating',
  298. likes: '.film-detail .attribution .icon-liked,.film-metadata .icon-liked,.review .like-link-target,.film-detail-content .like-link-target',
  299. comments: '.film-detail .attribution .content-metadata,#content #comments',
  300. withSpoilers: '.film-detail:has(.contains-spoilers)',
  301. withoutRatings: '.film-detail:not(:has(.rating))',
  302. },
  303. },
  304. homepageSections: {
  305. friendsHaveBeenWatching: '.person-home h1.title-hero span',
  306. newFromFriends: '.person-home section#recent-from-friends',
  307. popularWithFriends: '.person-home section#popular-with-friends',
  308. discoveryStream: '.person-home section.section-discovery-stream',
  309. latestNews: '.person-home section#latest-news:not(:has(.teaser-grid))',
  310. popularReviewsWithFriends: '.person-home section#popular-reviews',
  311. newListsFromFriends: '.person-home section:has([href="/lists/friends/"])',
  312. popularLists: '.person-home section:has([href="/lists/popular/this/week/"])',
  313. recentStories: '.person-home section.stories-section',
  314. recentShowdowns: '.person-home section:has([href="/showdown/"])',
  315. recentNews: '.person-home section#latest-news:has(.teaser-grid)',
  316. },
  317. processedClass: {
  318. apply: 'filterboxd-hide-processed',
  319. remove: 'filterboxd-unhide-processed',
  320. },
  321. settings: {
  322. clear: '.clear',
  323. favoriteFilms: '.favourite-films-selector',
  324. filteredTitleLinkClass: 'filtered-title-span',
  325. note: '.note',
  326. posterList: '.poster-list',
  327. removePendingClass: 'remove-pending',
  328. savedBadgeClass: 'filtered-saved',
  329. subNav: '.sub-nav',
  330. subtitle: '.mob-subtitle',
  331. tabbedContentId: '#tabbed-content',
  332. },
  333. userpanel: {
  334. self: '#userpanel',
  335. userscriptListItemId: 'filterboxd-userpanel-list-item',
  336. addThisFilm: '#userpanel .add-this-film',
  337. },
  338. };
  339.  
  340. function addListItemToPopMenu() {
  341. log(DEBUG, 'addListItemToPopMenu()');
  342.  
  343. const filmPosterPopMenus = document.querySelectorAll(SELECTORS.filmPosterPopMenu.self);
  344.  
  345. if (!filmPosterPopMenus) {
  346. log(`Selector ${SELECTORS.filmPosterPopMenu.self} not found`, DEBUG);
  347. return false;
  348. }
  349.  
  350. let pageUpdated = false;
  351.  
  352. filmPosterPopMenus.forEach(filmPosterPopMenu => {
  353. const userscriptListItemPresent = filmPosterPopMenu.querySelector(
  354. `.${SELECTORS.filmPosterPopMenu.userscriptListItemClass}`,
  355. );
  356. if (userscriptListItemPresent) return;
  357.  
  358. const lastListItem = filmPosterPopMenu.querySelector('li:last-of-type');
  359.  
  360. if (!lastListItem) {
  361. logError(`Selector ${SELECTORS.filmPosterPopMenu} li:last-of-type not found`);
  362. return;
  363. }
  364.  
  365. const unorderedList = filmPosterPopMenu.querySelector('ul');
  366. if (!unorderedList) {
  367. logError(`Selector ${SELECTORS.filmPosterPopMenu.self} ul not found`);
  368. return;
  369. }
  370.  
  371. modifyThenObserve(() => {
  372. let userscriptListItem = lastListItem.cloneNode(true);
  373. userscriptListItem.classList.add(SELECTORS.filmPosterPopMenu.userscriptListItemClass);
  374.  
  375. userscriptListItem = buildUserscriptLink(userscriptListItem, unorderedList);
  376. lastListItem.parentNode.append(userscriptListItem);
  377. });
  378.  
  379. pageUpdated = true;
  380. });
  381.  
  382. return pageUpdated;
  383. }
  384.  
  385. function addFilterToFilm({ id, slug }) {
  386. log(DEBUG, 'addFilterToFilm()');
  387.  
  388. let pageUpdated = false;
  389.  
  390. const idMatch = `[data-film-id="${id}"]`;
  391. let appliedSelector = `.${SELECTORS.processedClass.apply}`;
  392.  
  393. const replaceBehavior = gmcGet('filmBehaviorType') === 'Replace poster';
  394. log(VERBOSE, 'replaceBehavior', replaceBehavior);
  395.  
  396. if (replaceBehavior) appliedSelector = '[data-original-img-src]';
  397.  
  398. log(VERBOSE, 'Activity page reviews');
  399. document.querySelectorAll(`section.activity-row ${idMatch}`).forEach(posterElement => {
  400. applyFilterToFilm(posterElement, 3);
  401.  
  402. pageUpdated = true;
  403. });
  404.  
  405. log(VERBOSE, 'Activity page likes');
  406. document.querySelectorAll(`section.activity-row .activity-summary a[href*="${slug}"]:not(${appliedSelector})`).forEach(posterElement => {
  407. applyFilterToFilm(posterElement, 3);
  408.  
  409. pageUpdated = true;
  410. });
  411.  
  412. log(VERBOSE, 'New from friends');
  413. document.querySelectorAll(`.poster-container ${idMatch}:not(${appliedSelector})`).forEach(posterElement => {
  414. applyFilterToFilm(posterElement, 1);
  415.  
  416. pageUpdated = true;
  417. });
  418.  
  419. log(VERBOSE, 'Reviews');
  420. document.querySelectorAll(`.review-tile ${idMatch}:not(${appliedSelector})`).forEach(posterElement => {
  421. applyFilterToFilm(posterElement, 3);
  422.  
  423. pageUpdated = true;
  424. });
  425.  
  426. log(VERBOSE, 'Diary');
  427. document.querySelectorAll(`.td-film-details [data-original-img-src]${idMatch}:not(${appliedSelector})`).forEach(posterElement => {
  428. applyFilterToFilm(posterElement, 2);
  429.  
  430. pageUpdated = true;
  431. });
  432.  
  433. log(VERBOSE, 'Popular with friends, competitions');
  434. const remainingElements = document.querySelectorAll(
  435. `div:not(.popmenu):not(.actions-panel) ${idMatch}:not(aside [data-film-id="${id}"]):not(${appliedSelector})`,
  436. );
  437. remainingElements.forEach(posterElement => {
  438. applyFilterToFilm(posterElement, 0);
  439.  
  440. pageUpdated = true;
  441. });
  442.  
  443. return pageUpdated;
  444. }
  445.  
  446. function addToHiddenTitles(filmMetadata) {
  447. log(DEBUG, 'addToHiddenTitles()');
  448.  
  449. const filmFilter = getFilter('filmFilter');
  450. filmFilter.push(filmMetadata);
  451. log(VERBOSE, 'filmFilter', filmFilter);
  452.  
  453. setFilter('filmFilter', filmFilter);
  454. }
  455.  
  456. function applyFilters() {
  457. log(DEBUG, 'applyFilters()');
  458.  
  459. let pageUpdated = false;
  460.  
  461. const filmFilter = getFilter('filmFilter');
  462. log(VERBOSE, 'filmFilter', filmFilter);
  463.  
  464. const reviewFilter = getFilter('reviewFilter');
  465. log(VERBOSE, 'reviewFilter', reviewFilter);
  466.  
  467. const replaceBehavior = gmcGet('reviewBehaviorType') === 'Replace text';
  468. log(VERBOSE, 'replaceBehavior', replaceBehavior);
  469.  
  470. const reviewBehaviorReplaceValue = gmcGet('reviewBehaviorReplaceValue');
  471. log(VERBOSE, 'reviewBehaviorReplaceValue', reviewBehaviorReplaceValue);
  472.  
  473. const homepageFilter = getFilter('homepageFilter');
  474. log(VERBOSE, 'homepageFilter', homepageFilter);
  475.  
  476. const filmPageFilter = getFilter('filmPageFilter');
  477. log(VERBOSE, 'filmPageFilter', filmPageFilter);
  478.  
  479. modifyThenObserve(() => {
  480. filmFilter.forEach(filmMetadata => {
  481. const filmUpdated = addFilterToFilm(filmMetadata);
  482. if (filmUpdated) pageUpdated = true;
  483. });
  484.  
  485. const selectorReviewElementsToFilter = [];
  486. if (reviewFilter.ratings) selectorReviewElementsToFilter.push(SELECTORS.filter.reviews.ratings);
  487. if (reviewFilter.likes) selectorReviewElementsToFilter.push(SELECTORS.filter.reviews.likes);
  488. if (reviewFilter.comments) selectorReviewElementsToFilter.push(SELECTORS.filter.reviews.comments);
  489.  
  490. log(VERBOSE, 'selectorReviewElementsToFilter', selectorReviewElementsToFilter);
  491.  
  492. if (selectorReviewElementsToFilter.length) {
  493. document.querySelectorAll(selectorReviewElementsToFilter.join(',')).forEach(reviewElement => {
  494. reviewElement.style.display = 'none';
  495.  
  496. pageUpdated = true;
  497. });
  498. }
  499.  
  500. const reviewsToFilterSelectors = [];
  501. if (reviewFilter.withSpoilers) reviewsToFilterSelectors.push(SELECTORS.filter.reviews.withSpoilers);
  502. if (reviewFilter.withoutRatings) reviewsToFilterSelectors.push(SELECTORS.filter.reviews.withoutRatings);
  503.  
  504. log(VERBOSE, 'reviewsToFilterSelectors', reviewsToFilterSelectors);
  505.  
  506. if (reviewsToFilterSelectors.length) {
  507. document.querySelectorAll(reviewsToFilterSelectors.join(',')).forEach(review => {
  508. if (replaceBehavior) {
  509. review.querySelector('.body-text').innerText = reviewBehaviorReplaceValue;
  510. }
  511.  
  512. review.classList.add(SELECTORS.filter.reviewClass);
  513.  
  514. pageUpdated = true;
  515. });
  516. }
  517.  
  518. if (reviewFilter.byWordCount) {
  519. const reviewMinimumWordCount = getFilter('reviewMinimumWordCount');
  520. log(VERBOSE, 'reviewMinimumWordCount', reviewMinimumWordCount);
  521.  
  522. document.querySelectorAll('.film-detail:not(.filterboxd-filter-review)').forEach(review => {
  523. const reviewText = review.querySelector('.body-text').innerText;
  524. log(VERBOSE, 'reviewText', reviewText);
  525.  
  526. if (countWords(reviewText) >= reviewMinimumWordCount) return;
  527.  
  528. if (replaceBehavior) {
  529. review.querySelector('.body-text').innerText = reviewBehaviorReplaceValue;
  530. }
  531.  
  532. review.classList.add(SELECTORS.filter.reviewClass);
  533.  
  534. pageUpdated = true;
  535. });
  536. }
  537.  
  538. const sectionsToFilter = [];
  539.  
  540. const homepageSectionsToFilter = Object.keys(homepageFilter)
  541. .filter(key => homepageFilter[key])
  542. .map(key => SELECTORS.homepageSections[key])
  543. .filter(Boolean);
  544. log(VERBOSE, 'homepageSectionToFilter', homepageSectionsToFilter);
  545.  
  546. const filmPageSectionsToFilter = Object.keys(filmPageFilter)
  547. .filter(key => filmPageFilter[key])
  548. .map(key => SELECTORS.filmPageSections[key])
  549. .filter(Boolean);
  550. log(VERBOSE, 'filmPageSectionsToFilter', filmPageSectionsToFilter);
  551.  
  552. sectionsToFilter.push(...homepageSectionsToFilter);
  553. sectionsToFilter.push(...filmPageSectionsToFilter);
  554.  
  555. if (sectionsToFilter.length) {
  556. document.querySelectorAll(sectionsToFilter.join(',')).forEach(filterSection => {
  557. filterSection.style.display = 'none';
  558.  
  559. pageUpdated = true;
  560. });
  561. }
  562.  
  563. if (filmPageFilter.backdropImage) {
  564. document.querySelector('#content.-backdrop')?.classList.remove('-backdrop');
  565. pageUpdated = true;
  566. }
  567. });
  568.  
  569. return pageUpdated;
  570. }
  571.  
  572. function applyFilterToFilm(element, levelsUp = 0) {
  573. log(DEBUG, 'applyFilterToFilm()');
  574.  
  575. const replaceBehavior = gmcGet('filmBehaviorType') === 'Replace poster';
  576. log(VERBOSE, 'replaceBehavior', replaceBehavior);
  577.  
  578. if (replaceBehavior) {
  579. const filmBehaviorReplaceValue = gmcGet('filmBehaviorReplaceValue');
  580. log(VERBOSE, 'filmBehaviorReplaceValue', filmBehaviorReplaceValue);
  581.  
  582. const elementImg = element.querySelector('img');
  583. if (!elementImg) return;
  584.  
  585. const originalImgSrc = elementImg.src;
  586. if (!originalImgSrc) return;
  587.  
  588. if (originalImgSrc === filmBehaviorReplaceValue) return;
  589.  
  590. element.setAttribute('data-original-img-src', originalImgSrc);
  591.  
  592. element.querySelector('img').src = filmBehaviorReplaceValue;
  593. element.querySelector('img').srcset = filmBehaviorReplaceValue;
  594.  
  595. element.classList.add(SELECTORS.processedClass.apply);
  596. element.classList.remove(SELECTORS.processedClass.remove);
  597. } else {
  598. let target = element;
  599.  
  600. for (let i = 0; i < levelsUp; i++) {
  601. if (target.parentNode) {
  602. target = target.parentNode;
  603. } else {
  604. break;
  605. }
  606. }
  607.  
  608. log(VERBOSE, 'target', target);
  609.  
  610. target.classList.add(SELECTORS.filter.filmClass);
  611. element.classList.add(SELECTORS.processedClass.apply);
  612. element.classList.remove(SELECTORS.processedClass.remove);
  613. }
  614. }
  615.  
  616. function buildBehaviorFormRows(parentDiv, filterName, selectArrayValues, behaviorsMetadata) {
  617. const behaviorValue = gmcGet(`${filterName}BehaviorType`);
  618. log(DEBUG, 'behaviorValue', behaviorValue);
  619.  
  620. const behaviorChange = (event) => {
  621. const filmBehaviorType = event.target.value;
  622. updateBehaviorCSSVariables(filterName, filmBehaviorType);
  623. };
  624.  
  625. const behaviorFormRow = createFormRow({
  626. formRowStyle: `width: ${COLUMN_ONE_WIDTH};`,
  627. labelText: 'Behavior',
  628. inputValue: behaviorValue,
  629. inputType: 'select',
  630. selectArray: selectArrayValues,
  631. selectOnChange: behaviorChange,
  632. });
  633.  
  634. parentDiv.appendChild(behaviorFormRow);
  635.  
  636. // Fade amount
  637. const behaviorFadeAmount = parseInt(gmcGet(behaviorsMetadata.fade.fieldName));
  638. log(DEBUG, 'behaviorFadeAmount', behaviorFadeAmount);
  639.  
  640. const fadeAmountFormRow = createFormRow({
  641. formRowClass: ['update-details'],
  642. formRowStyle: `width: ${COLUMN_TWO_WIDTH}; float: right; display: var(--filterboxd-${filterName}-behavior-fade);`,
  643. labelText: 'Opacity',
  644. inputValue: behaviorFadeAmount,
  645. inputType: 'number',
  646. inputMin: 0,
  647. inputMax: 100,
  648. inputStyle: 'width: 100px !important;',
  649. notes: '%',
  650. notesStyle: 'width: 10px; margin-left: 14px;',
  651. });
  652.  
  653. parentDiv.appendChild(fadeAmountFormRow);
  654.  
  655. // Blur amount
  656. const behaviorBlurAmount = parseInt(gmcGet(behaviorsMetadata.blur.fieldName));
  657. log(DEBUG, 'behaviorBlurAmount', behaviorBlurAmount);
  658.  
  659. const blurAmountFormRow = createFormRow({
  660. formRowClass: ['update-details'],
  661. formRowStyle: `width: ${COLUMN_TWO_WIDTH}; float: right; display: var(--filterboxd-${filterName}-behavior-blur);`,
  662. labelText: 'Amount',
  663. inputValue: behaviorBlurAmount,
  664. inputType: 'number',
  665. inputMin: 1,
  666. inputStyle: 'width: 100px !important;',
  667. notes: 'px',
  668. notesStyle: 'width: 10px; margin-left: 14px;',
  669. });
  670.  
  671. parentDiv.appendChild(blurAmountFormRow);
  672.  
  673. // Replace value
  674. const behaviorReplaceValue = gmcGet(behaviorsMetadata.replace.fieldName);
  675. log(DEBUG, 'behaviorReplaceValue', behaviorReplaceValue);
  676.  
  677. const replaceValueFormRow = createFormRow({
  678. formRowStyle: `width: ${COLUMN_TWO_WIDTH}; float: right; display: var(--filterboxd-${filterName}-behavior-replace);`,
  679. labelText: behaviorsMetadata.replace.labelText,
  680. inputValue: behaviorReplaceValue,
  681. inputType: 'text',
  682. });
  683.  
  684. parentDiv.appendChild(replaceValueFormRow);
  685.  
  686. // Custom CSS
  687. const behaviorCustomValue = gmcGet(behaviorsMetadata.custom.fieldName);
  688. log(DEBUG, 'behaviorCustomValue', behaviorCustomValue);
  689.  
  690. const cssFormRow = createFormRow({
  691. formRowStyle: `width: ${COLUMN_TWO_WIDTH}; float: right; display: var(--filterboxd-${filterName}-behavior-custom);`,
  692. labelText: 'CSS',
  693. inputValue: behaviorCustomValue,
  694. inputType: 'text',
  695. });
  696.  
  697. parentDiv.appendChild(cssFormRow);
  698.  
  699. return [
  700. behaviorFormRow,
  701. fadeAmountFormRow,
  702. blurAmountFormRow,
  703. replaceValueFormRow,
  704. behaviorCustomValue,
  705. ];
  706. }
  707.  
  708. function buildToggleSectionListItems(filterName, unorderedList, listItemMetadata) {
  709. log(DEBUG, 'buildListItemToggles()');
  710.  
  711. const filter = getFilter(filterName);
  712.  
  713. listItemMetadata.forEach(metadata => {
  714. const { type, name, description } = metadata;
  715.  
  716. if (type === 'label') {
  717. const label = document.createElement('label');
  718. unorderedList.appendChild(label);
  719.  
  720. label.innerText = description;
  721. label.style.cssText = 'margin: 1em 0em;';
  722.  
  723. return;
  724. }
  725.  
  726. const checked = filter[name] || false;
  727.  
  728. const listItem = document.createElement('li');
  729. listItem.classList.add('option');
  730.  
  731. const label = document.createElement('label');
  732. listItem.appendChild(label);
  733.  
  734. label.classList.add('option-label', '-toggle', 'switch-control');
  735.  
  736. const labelSpan = document.createElement('span');
  737. label.appendChild(labelSpan);
  738.  
  739. labelSpan.classList.add('label');
  740. labelSpan.innerText = description;
  741.  
  742. const labelInput = document.createElement('input');
  743. label.appendChild(labelInput);
  744.  
  745. labelInput.classList.add('checkbox');
  746. labelInput.setAttribute('type', 'checkbox');
  747. labelInput.setAttribute('role', 'switch');
  748. labelInput.setAttribute('data-filter-name', filterName);
  749. labelInput.setAttribute('data-field-name', name);
  750. labelInput.checked = checked;
  751.  
  752. const labelCheckboxSpan = document.createElement('span');
  753. label.appendChild(labelCheckboxSpan);
  754.  
  755. labelCheckboxSpan.classList.add('state');
  756.  
  757. const checkboxTrackSpan = document.createElement('span');
  758. labelCheckboxSpan.appendChild(checkboxTrackSpan);
  759.  
  760. checkboxTrackSpan.classList.add('track');
  761.  
  762. const checkboxHandleSpan = document.createElement('span');
  763. checkboxTrackSpan.appendChild(checkboxHandleSpan);
  764.  
  765. checkboxHandleSpan.classList.add('handle');
  766.  
  767. unorderedList.appendChild(listItem);
  768. });
  769. }
  770.  
  771. function buildUserscriptLink(userscriptListItem, unorderedList) {
  772. log(DEBUG, 'buildUserscriptLink()');
  773.  
  774. const userscriptLink = userscriptListItem.firstElementChild;
  775. userscriptListItem.onclick = (event) => {
  776. event.preventDefault();
  777.  
  778. log(DEBUG, 'userscriptListItem clicked');
  779. log(VERBOSE, 'event', event);
  780.  
  781. const link = event.target;
  782. log(VERBOSE, 'link', link);
  783.  
  784. const id = parseInt(link.getAttribute('data-film-id'));
  785. const slug = link.getAttribute('data-film-slug');
  786. const name = link.getAttribute('data-film-name');
  787. const year = link.getAttribute('data-film-release-year');
  788.  
  789. const filmMetadata = {
  790. id,
  791. slug,
  792. name,
  793. year,
  794. };
  795.  
  796. const titleIsHidden = link.getAttribute('data-title-hidden') === 'true';
  797.  
  798. modifyThenObserve(() => {
  799. if (titleIsHidden) {
  800. removeFilterFromFilm(filmMetadata);
  801. removeFromFilmFilter(filmMetadata);
  802. } else {
  803. addFilterToFilm(filmMetadata);
  804. addToHiddenTitles(filmMetadata);
  805. }
  806.  
  807. const sidebarLink = document.querySelector(createId(SELECTORS.userpanel.userscriptListItemId));
  808. if (sidebarLink) {
  809. updateLinkInPopMenu(!titleIsHidden, sidebarLink);
  810.  
  811. const popupLink = document.querySelector(`.${SELECTORS.filmPosterPopMenu.userscriptListItemClass} a`);
  812. if (popupLink) updateLinkInPopMenu(!titleIsHidden, popupLink);
  813. } else {
  814. updateLinkInPopMenu(!titleIsHidden, link);
  815. }
  816. });
  817. };
  818.  
  819. let filmPosterSelector;
  820.  
  821. let titleId = unorderedList.querySelector('[data-film-id]')?.getAttribute('data-film-id');
  822. log(DEBUG, 'titleId', titleId);
  823.  
  824. if (titleId) {
  825. filmPosterSelector = `[data-film-id='${titleId}'].film-poster`;
  826. } else {
  827. const titleName = unorderedList.querySelector('[data-film-name]')?.getAttribute('data-film-name');
  828. log(DEBUG, 'titleName', titleName);
  829.  
  830. if (titleName) {
  831. filmPosterSelector = `[data-film-name='${titleName}'].film-poster`;
  832. } else {
  833. logError('No film id or name found in unordered list');
  834. return;
  835. }
  836. }
  837.  
  838. log(DEBUG, 'filmPosterSelector', filmPosterSelector);
  839. const filmPoster = document.querySelector(filmPosterSelector);
  840. log(DEBUG, 'filmPoster', filmPoster);
  841.  
  842. if (!titleId) {
  843. titleId = filmPoster?.getAttribute('data-film-id');
  844. log(DEBUG, 'titleId', titleId);
  845.  
  846. if (!titleId) {
  847. logError('No film id found on film poster');
  848. return;
  849. }
  850. }
  851.  
  852. userscriptLink.setAttribute('data-film-id', titleId);
  853.  
  854. if (!filmPoster) {
  855. logError('No film poster found');
  856. log(INFO, 'unorderedList', unorderedList);
  857. }
  858.  
  859. const titleSlug =
  860. unorderedList.querySelector('[data-film-slug]')?.getAttribute('data-film-slug')
  861. || filmPoster?.getAttribute('data-film-slug');
  862. log(DEBUG, 'titleSlug', titleSlug);
  863.  
  864. if (titleSlug) userscriptLink.setAttribute('data-film-slug', titleSlug);
  865.  
  866. const titleName = unorderedList.querySelector('[data-film-name]')?.getAttribute('data-film-name');
  867. log(DEBUG, 'titleName', titleName);
  868. if (titleName) userscriptLink.setAttribute('data-film-name', titleName);
  869.  
  870. // Title year isn't present in the pop menu list, so retrieve it from the film poster
  871. const titleYear =
  872. filmPoster?.querySelector('.has-menu')?.getAttribute('data-original-title')?.match(/\((\d{4})\)/)?.[1]
  873. || document.querySelector('div.releaseyear a')?.innerText
  874. || document.querySelector('small.metadata a')?.innerText
  875. || filmPoster?.querySelector('.frame-title')?.innerText?.match(/\((\d{4})\)/)?.[1];
  876. log(DEBUG, 'titleYear', titleYear);
  877. if (titleYear) userscriptLink.setAttribute('data-film-release-year', titleYear);
  878.  
  879. const filmFilter = getFilter('filmFilter');
  880. log(DEBUG, 'filmFilter', filmFilter);
  881.  
  882. const titleIsHidden = filmFilter.some(
  883. filteredFilm => filteredFilm.id?.toString() === titleId?.toString(),
  884. );
  885. log(DEBUG, 'titleIsHidden', titleIsHidden);
  886.  
  887. updateLinkInPopMenu(titleIsHidden, userscriptLink);
  888.  
  889. userscriptLink.removeAttribute('class');
  890.  
  891. return userscriptListItem;
  892. }
  893.  
  894. function buildToggleSection(parentElement, sectionTitle, filerName, sectionMetadata) {
  895. log(DEBUG, 'buildToggleSection()');
  896.  
  897. const formRowDiv = document.createElement('div');
  898. parentElement.appendChild(formRowDiv);
  899.  
  900. formRowDiv.style.cssText = 'margin-bottom: 40px;';
  901.  
  902. const sectionHeader = document.createElement('h3');
  903. formRowDiv.append(sectionHeader);
  904.  
  905. sectionHeader.classList.add('title-3');
  906. sectionHeader.style.cssText = 'margin-top: 0em;';
  907. sectionHeader.innerText = sectionTitle;
  908.  
  909. const unorderedList = document.createElement('ul');
  910. formRowDiv.append(unorderedList);
  911.  
  912. unorderedList.classList.add('options-list', '-toggle-list', 'js-toggle-list');
  913.  
  914. buildToggleSectionListItems(
  915. filerName,
  916. unorderedList,
  917. sectionMetadata,
  918. );
  919.  
  920. let formColumnDiv = document.createElement('div');
  921. formRowDiv.appendChild(formColumnDiv);
  922.  
  923. formColumnDiv.classList.add('form-columns', '-cols2');
  924. }
  925.  
  926. function createFormRow({
  927. formRowClass = [],
  928. formRowStyle = '',
  929. labelText = '',
  930. helpText = '',
  931. inputValue = '',
  932. inputType = 'text',
  933. inputMin = null,
  934. inputMax = null,
  935. inputStyle = '',
  936. selectArray = [],
  937. selectOnChange = () => {},
  938. notes = '',
  939. notesStyle = '',
  940. }) {
  941. log(DEBUG, 'createFormRow()');
  942.  
  943. const formRow = document.createElement('div');
  944. formRow.classList.add('form-row');
  945. formRow.style.cssText = formRowStyle;
  946. formRow.classList.add(...formRowClass);
  947.  
  948. const selectList = document.createElement('div');
  949. formRow.appendChild(selectList);
  950.  
  951. selectList.classList.add('select-list');
  952.  
  953. const label = document.createElement('label');
  954. selectList.appendChild(label);
  955.  
  956. label.classList.add('label');
  957. label.textContent = labelText;
  958.  
  959. if (helpText) {
  960. const helpIcon = document.createElement('span');
  961. label.appendChild(helpIcon);
  962.  
  963. helpIcon.classList.add('s', 'icon-14', 'icon-tip', 'tooltip');
  964. helpIcon.setAttribute('target', '_blank');
  965. helpIcon.setAttribute('data-html', 'true');
  966. helpIcon.setAttribute('data-original-title', helpText);
  967. helpIcon.innerHTML = '<span class="icon"></span>(Help)';
  968. }
  969.  
  970. const inputDiv = document.createElement('div');
  971. selectList.appendChild(inputDiv);
  972.  
  973. inputDiv.classList.add('input');
  974. inputDiv.style.cssText = inputStyle;
  975.  
  976. if (inputType === 'select') {
  977. const select = document.createElement('select');
  978. inputDiv.appendChild(select);
  979.  
  980. select.classList.add('select');
  981.  
  982. selectArray.forEach(option => {
  983. const optionElement = document.createElement('option');
  984. select.appendChild(optionElement);
  985.  
  986. optionElement.value = option;
  987. optionElement.textContent = option;
  988.  
  989. if (option === inputValue) optionElement.setAttribute('selected', 'selected');
  990. });
  991.  
  992. select.onchange = selectOnChange;
  993. } else if (['text', 'number'].includes(inputType)) {
  994. const input = document.createElement('input');
  995. inputDiv.appendChild(input);
  996.  
  997. input.type = inputType;
  998. input.classList.add('field');
  999. input.value = inputValue;
  1000.  
  1001. if (inputMin !== null) input.min = inputMin;
  1002. if (inputMax !== null) input.max = inputMax;
  1003. }
  1004.  
  1005. if (notes) {
  1006. const notesElement = document.createElement('p');
  1007. selectList.appendChild(notesElement);
  1008.  
  1009. notesElement.classList.add('notes');
  1010. notesElement.style.cssText = notesStyle;
  1011. notesElement.textContent = notes;
  1012. }
  1013.  
  1014. return formRow;
  1015. }
  1016.  
  1017. function displaySavedBadge() {
  1018. log(DEBUG, 'displaySavedBadge()');
  1019.  
  1020. const savedBadge = document.querySelector(`.${SELECTORS.settings.savedBadgeClass}`);
  1021.  
  1022. savedBadge.classList.remove('hidden');
  1023. savedBadge.classList.add('fade');
  1024.  
  1025. setTimeout(() => {
  1026. savedBadge.classList.add('fade-out');
  1027. }, 2000);
  1028.  
  1029. setTimeout(() => {
  1030. savedBadge.classList.remove('fade', 'fade-out');
  1031. savedBadge.classList.add('hidden');
  1032. }, 3000);
  1033. }
  1034.  
  1035. function getFilter(filterName) {
  1036. log(DEBUG, 'getFilter()');
  1037.  
  1038. return JSON.parse(gmcGet(filterName));
  1039. }
  1040.  
  1041. function getFilterBehaviorStyle(filterName) {
  1042. log(DEBUG, 'getFilterBehaviorStyle()');
  1043.  
  1044. let behaviorStyle;
  1045. let behaviorType = gmcGet(`${filterName}BehaviorType`);
  1046. log(DEBUG, 'behaviorType', behaviorType);
  1047.  
  1048. const behaviorFadeAmount = gmcGet(`${filterName}BehaviorFadeAmount`);
  1049. log(VERBOSE, 'behaviorFadeAmount', behaviorFadeAmount);
  1050.  
  1051. const behaviorBlurAmount = gmcGet(`${filterName}BehaviorBlurAmount`);
  1052. log(VERBOSE, 'behaviorBlurAmount', behaviorBlurAmount);
  1053.  
  1054. const behaviorCustomValue = gmcGet(`${filterName}BehaviorCustomValue`);
  1055. log(VERBOSE, 'behaviorCustomValue', behaviorCustomValue);
  1056.  
  1057. switch (behaviorType) {
  1058. case 'Remove':
  1059. behaviorStyle = 'display: none !important;';
  1060. break;
  1061. case 'Fade':
  1062. behaviorStyle = `opacity: ${behaviorFadeAmount}%`;
  1063. break;
  1064. case 'Blur':
  1065. behaviorStyle = `filter: blur(${behaviorBlurAmount}px)`;
  1066. break;
  1067. case 'Custom':
  1068. behaviorStyle = behaviorCustomValue;
  1069. break;
  1070. }
  1071.  
  1072. updateBehaviorCSSVariables(filterName, behaviorType);
  1073.  
  1074. return behaviorStyle;
  1075. }
  1076.  
  1077. function gmcInitialized() {
  1078. log(DEBUG, 'gmcInitialized()');
  1079. log(QUIET, 'Running');
  1080.  
  1081. GMC.css.basic = '';
  1082.  
  1083. if (RESET_DATA) {
  1084. log(QUIET, 'Resetting GMC');
  1085.  
  1086. for (const [key, field] of Object.entries(GMC_FIELDS)) {
  1087. const value = field.default;
  1088. gmcSet(key, value);
  1089. }
  1090.  
  1091. log(QUIET, 'GMC reset');
  1092. }
  1093.  
  1094. let userscriptStyle = document.createElement('style');
  1095. userscriptStyle.setAttribute('id', 'filterboxd-style');
  1096.  
  1097. const filmBehaviorStyle = getFilterBehaviorStyle('film');
  1098. log(VERBOSE, 'filmBehaviorStyle', filmBehaviorStyle);
  1099.  
  1100. const reviewBehaviorStyle = getFilterBehaviorStyle('review');
  1101. log(VERBOSE, 'reviewBehaviorStyle', reviewBehaviorStyle);
  1102.  
  1103. userscriptStyle.textContent += `
  1104. .${SELECTORS.filter.filmClass}
  1105. {
  1106. ${filmBehaviorStyle}
  1107. }
  1108.  
  1109. .${SELECTORS.filter.reviewClass}
  1110. {
  1111. ${reviewBehaviorStyle}
  1112. }
  1113.  
  1114. .${SELECTORS.settings.filteredTitleLinkClass}
  1115. {
  1116. cursor: pointer;
  1117. margin-right: 0.3rem !important;
  1118. }
  1119.  
  1120. .${SELECTORS.settings.filteredTitleLinkClass}:hover
  1121. {
  1122. background: #303840;
  1123. color: #def;
  1124. }
  1125.  
  1126. .${SELECTORS.settings.removePendingClass}
  1127. {
  1128. outline: 1px dashed #ee7000;
  1129. outline-offset: -1px;
  1130. }
  1131.  
  1132. .hidden {
  1133. visibility: hidden;
  1134. }
  1135.  
  1136. .fade {
  1137. opacity: 1;
  1138. transition: opacity 1s ease-out;
  1139. }
  1140.  
  1141. .fade.fade-out {
  1142. opacity: 0;
  1143. }
  1144. `;
  1145. document.body.appendChild(userscriptStyle);
  1146.  
  1147. const onSettingsPage = window.location.href.includes('/settings/');
  1148. log(VERBOSE, 'onSettingsPage', onSettingsPage);
  1149.  
  1150. if (onSettingsPage) {
  1151. maybeAddConfigurationToSettings();
  1152. }
  1153. else {
  1154. applyFilters();
  1155. startObserving();
  1156. }
  1157. }
  1158.  
  1159. function trySelectFilterboxdTab() {
  1160. const maxAttempts = 10;
  1161. let attempts = 0;
  1162. let successes = 0;
  1163.  
  1164. log(DEBUG, `Attempting to select Filterboxd tab (attempt ${attempts + 1}/${maxAttempts})`);
  1165.  
  1166. const tabLink = document.querySelector('a[data-id="filterboxd"]');
  1167. if (!tabLink) {
  1168. log(DEBUG, 'Filterboxd tab link not found yet');
  1169. if (attempts < maxAttempts) {
  1170. attempts++;
  1171. setTimeout(trySelectFilterboxdTab, 100);
  1172. } else {
  1173. logError('Failed to find Filterboxd tab after maximum attempts');
  1174. }
  1175. return;
  1176. }
  1177.  
  1178. try {
  1179. tabLink.click();
  1180.  
  1181. setTimeout(() => {
  1182. const tabSelected = document.querySelector('li.selected:has(a[data-id="filterboxd"])') !== null;
  1183. if (tabSelected) {
  1184. log(DEBUG, 'Filterboxd tab selected successfully');
  1185. successes++;
  1186.  
  1187. // There's a race condition between the click and the "Profile" tab being loaded and selected
  1188. if (successes < 2) setTimeout(trySelectFilterboxdTab, 500);
  1189. } else {
  1190. log(DEBUG, 'Click didn\'t select the tab properly');
  1191. if (attempts < maxAttempts) {
  1192. attempts++;
  1193. setTimeout(trySelectFilterboxdTab, 100);
  1194. } else {
  1195. logError('Failed to select Filterboxd tab after maximum attempts');
  1196. }
  1197. }
  1198. }, 50);
  1199. } catch (error) {
  1200. logError('Error selecting Filterboxd tab', error);
  1201. if (attempts < maxAttempts) {
  1202. attempts++;
  1203. setTimeout(trySelectFilterboxdTab, 100);
  1204. }
  1205. }
  1206. }
  1207.  
  1208. function maybeAddConfigurationToSettings() {
  1209. log(DEBUG, 'maybeAddConfigurationToSettings()');
  1210.  
  1211. const userscriptTabId = 'tab-filterboxd';
  1212. const configurationExists = document.querySelector(createId(userscriptTabId));
  1213. log(VERBOSE, 'configurationExists', configurationExists);
  1214.  
  1215. if (configurationExists) {
  1216. log(DEBUG, 'Filterboxd configuration tab is present');
  1217. return;
  1218. }
  1219.  
  1220. const userscriptTabDiv = document.createElement('div');
  1221.  
  1222. const settingsTabbedContent = document.querySelector(SELECTORS.settings.tabbedContentId);
  1223. settingsTabbedContent.appendChild(userscriptTabDiv);
  1224.  
  1225. userscriptTabDiv.setAttribute('id', userscriptTabId);
  1226. userscriptTabDiv.classList.add('tabbed-content-block');
  1227.  
  1228. const tabTitle = document.createElement('h2');
  1229. userscriptTabDiv.append(tabTitle);
  1230.  
  1231. tabTitle.style.cssText = 'margin-bottom: 1em;';
  1232. tabTitle.innerText = 'Filterboxd';
  1233.  
  1234. const tabPrimaryColumn = document.createElement('div');
  1235. userscriptTabDiv.append(tabPrimaryColumn);
  1236.  
  1237. tabPrimaryColumn.classList.add('col-10', 'overflow');
  1238.  
  1239. const asideColumn = document.createElement('aside');
  1240. userscriptTabDiv.append(asideColumn);
  1241.  
  1242. asideColumn.classList.add('col-12', 'overflow', 'col-right', 'js-hide-in-app');
  1243.  
  1244. // Filter film page
  1245. const filmPageFilterMetadata = [
  1246. {
  1247. type: 'toggle',
  1248. name: 'backdropImage',
  1249. description: 'Remove backdrop image',
  1250. },
  1251. {
  1252. type: 'label',
  1253. description: 'Left column',
  1254. },
  1255. {
  1256. type: 'toggle',
  1257. name: 'poster',
  1258. description: 'Remove poster',
  1259. },
  1260. {
  1261. type: 'toggle',
  1262. name: 'stats',
  1263. description: 'Remove Letterboxd stats',
  1264. },
  1265. {
  1266. type: 'toggle',
  1267. name: 'whereToWatch',
  1268. description: 'Remove "Where to watch" section',
  1269. },
  1270. {
  1271. type: 'label',
  1272. description: 'Right column',
  1273. },
  1274. {
  1275. type: 'toggle',
  1276. name: 'userActionsPanel',
  1277. description: 'Remove user actions panel',
  1278. },
  1279. {
  1280. type: 'toggle',
  1281. name: 'ratings',
  1282. description: 'Remove "Ratings" section',
  1283. },
  1284. {
  1285. type: 'label',
  1286. description: 'Middle column',
  1287. },
  1288. {
  1289. type: 'toggle',
  1290. name: 'releaseYear',
  1291. description: 'Remove release year text',
  1292. },
  1293. {
  1294. type: 'toggle',
  1295. name: 'director',
  1296. description: 'Remove director text',
  1297. },
  1298. {
  1299. type: 'toggle',
  1300. name: 'tagline',
  1301. description: 'Remove tagline text',
  1302. },
  1303. {
  1304. type: 'toggle',
  1305. name: 'description',
  1306. description: 'Remove description text',
  1307. },
  1308. {
  1309. type: 'toggle',
  1310. name: 'castTab',
  1311. description: 'Remove "Cast" tab',
  1312. },
  1313. {
  1314. type: 'toggle',
  1315. name: 'crewTab',
  1316. description: 'Remove "Crew" tab',
  1317. },
  1318. {
  1319. type: 'toggle',
  1320. name: 'detailsTab',
  1321. description: 'Remove "Details" tab',
  1322. },
  1323. {
  1324. type: 'toggle',
  1325. name: 'genresTab',
  1326. description: 'Remove "Genres" tab',
  1327. },
  1328. {
  1329. type: 'toggle',
  1330. name: 'releasesTab',
  1331. description: 'Remove "Releases" tab',
  1332. },
  1333. {
  1334. type: 'toggle',
  1335. name: 'activityFromFriends',
  1336. description: 'Remove "Activity from friends" section',
  1337. },
  1338. {
  1339. type: 'toggle',
  1340. name: 'filmNews',
  1341. description: 'Remove HQ film news section',
  1342. },
  1343. {
  1344. type: 'toggle',
  1345. name: 'reviewsFromFriends',
  1346. description: 'Remove "Reviews from friends" section',
  1347. },
  1348. {
  1349. type: 'toggle',
  1350. name: 'popularReviews',
  1351. description: 'Remove "Popular reviews" section',
  1352. },
  1353. {
  1354. type: 'toggle',
  1355. name: 'recentReviews',
  1356. description: 'Remove "Recent reviews" section',
  1357. },
  1358. {
  1359. type: 'toggle',
  1360. name: 'relatedFilms',
  1361. description: 'Remove "Related films" section',
  1362. },
  1363. {
  1364. type: 'toggle',
  1365. name: 'similarFilms',
  1366. description: 'Remove "Similar films" section',
  1367. },
  1368. {
  1369. type: 'toggle',
  1370. name: 'mentionedBy',
  1371. description: 'Remove "Mentioned by" section',
  1372. },
  1373. {
  1374. type: 'toggle',
  1375. name: 'popularLists',
  1376. description: 'Remove "Popular lists" section',
  1377. },
  1378. ];
  1379.  
  1380. buildToggleSection(
  1381. asideColumn,
  1382. 'Film Page Filter',
  1383. 'filmPageFilter',
  1384. filmPageFilterMetadata,
  1385. );
  1386.  
  1387. // Advanced Options
  1388. const formRowDiv = document.createElement('div');
  1389. asideColumn.appendChild(formRowDiv);
  1390.  
  1391. formRowDiv.style.cssText = 'margin-bottom: 40px;';
  1392.  
  1393. const sectionHeader = document.createElement('h3');
  1394. formRowDiv.append(sectionHeader);
  1395.  
  1396. sectionHeader.classList.add('title-3');
  1397. sectionHeader.style.cssText = 'margin-top: 0em;';
  1398. sectionHeader.innerText = 'Advanced Options';
  1399.  
  1400. const logLevelValue = gmcGet('logLevel');
  1401. log(DEBUG, 'logLevelValue', logLevelValue);
  1402.  
  1403. const logLevelFormRow = createFormRow({
  1404. formRowClass: ['update-details'],
  1405. formRowStyle: `width: ${COLUMN_ONE_WIDTH};`,
  1406. labelText: 'Log level ',
  1407. helpText: 'Determines how much logging<br /> is visible in the browser console',
  1408. inputValue: logLevelValue,
  1409. inputType: 'select',
  1410. selectArray: LOG_LEVELS.options,
  1411. });
  1412.  
  1413. formRowDiv.appendChild(logLevelFormRow);
  1414.  
  1415. const mutationsDiv = document.createElement('div');
  1416. mutationsDiv.style.cssText = 'display: flex; align-items: center;';
  1417. formRowDiv.append(mutationsDiv);
  1418.  
  1419. const maxActiveMutationsValue = gmcGet('maxActiveMutations');
  1420. log(DEBUG, 'maxActiveMutationsValue', maxActiveMutationsValue);
  1421.  
  1422. const maxActiveMutationsFormRow = createFormRow({
  1423. formRowClass: ['update-details'],
  1424. formRowStyle: `width: ${COLUMN_HALF_WIDTH};`,
  1425. labelText: 'Max active mutations ',
  1426. helpText: 'Safety limit that halts execution<br /> when a certain number of modifications<br /> are performed by the script',
  1427. inputValue: maxActiveMutationsValue,
  1428. inputType: 'number',
  1429. inputMin: 1,
  1430. inputStyle: 'width: 100px !important;',
  1431. });
  1432.  
  1433. mutationsDiv.appendChild(maxActiveMutationsFormRow);
  1434.  
  1435. const maxIdleMutationsValue = gmcGet('maxIdleMutations');
  1436. log(DEBUG, 'maxIdleMutationsValue', maxIdleMutationsValue);
  1437.  
  1438. const maxIdleMutationsFormRow = createFormRow({
  1439. formRowClass: ['update-details'],
  1440. formRowStyle: `width: ${COLUMN_HALF_WIDTH}; float: right;`,
  1441. labelText: 'Max idle mutations ',
  1442. helpText: 'Safety limit that halts execution<br /> when a certain number of modifications<br /> are performed by Letterboxd<br /> that did not result in modifications<br /> from the script',
  1443. inputValue: maxIdleMutationsValue,
  1444. inputType: 'number',
  1445. inputMin: 1,
  1446. inputStyle: 'width: 100px !important;',
  1447. });
  1448.  
  1449. mutationsDiv.appendChild(maxIdleMutationsFormRow);
  1450.  
  1451. let formColumnDiv = document.createElement('div');
  1452. formRowDiv.appendChild(formColumnDiv);
  1453.  
  1454. formColumnDiv.classList.add('form-columns', '-cols2');
  1455.  
  1456. // Filter films
  1457. const favoriteFilmsDiv = document.querySelector(SELECTORS.settings.favoriteFilms);
  1458. const filteredFilmsDiv = favoriteFilmsDiv.cloneNode(true);
  1459. tabPrimaryColumn.appendChild(filteredFilmsDiv);
  1460.  
  1461. filteredFilmsDiv.style.cssText = 'margin-bottom: 20px;';
  1462.  
  1463. const posterList = filteredFilmsDiv.querySelector(SELECTORS.settings.posterList);
  1464. posterList.remove();
  1465.  
  1466. filteredFilmsDiv.querySelector(SELECTORS.settings.subtitle).innerText = 'Films Filter';
  1467. filteredFilmsDiv.querySelector(SELECTORS.settings.note).innerText =
  1468. 'Right click to mark for removal.';
  1469.  
  1470. let hiddenTitlesDiv = document.createElement('div');
  1471. filteredFilmsDiv.append(hiddenTitlesDiv);
  1472.  
  1473. const hiddenTitlesParagraph = document.createElement('p');
  1474. hiddenTitlesDiv.appendChild(hiddenTitlesParagraph);
  1475.  
  1476. hiddenTitlesDiv.classList.add('text-sluglist');
  1477.  
  1478. const filmFilter = getFilter('filmFilter');
  1479. log(VERBOSE, 'filmFilter', filmFilter);
  1480.  
  1481. filmFilter.forEach((filteredFilm, index) => {
  1482. log(VERBOSE, 'filteredFilm', filteredFilm);
  1483.  
  1484. let filteredTitleLink = document.createElement('a');
  1485. hiddenTitlesParagraph.appendChild(filteredTitleLink);
  1486.  
  1487. if (filteredFilm.slug) filteredTitleLink.href= `/film/${filteredFilm.slug}`;
  1488.  
  1489. filteredTitleLink.classList.add(
  1490. 'text-slug',
  1491. SELECTORS.processedClass.apply,
  1492. SELECTORS.settings.filteredTitleLinkClass,
  1493. );
  1494. filteredTitleLink.setAttribute('data-film-id', filteredFilm.id);
  1495. filteredTitleLink.setAttribute('index', index);
  1496.  
  1497. let titleLinkText = filteredFilm.name;
  1498. if (['', null, undefined].includes(filteredFilm.name)) {
  1499. log(INFO, 'filteredFilm has no name; marking as broken', filteredFilm);
  1500. titleLinkText = 'Broken, please remove';
  1501. }
  1502.  
  1503. if (!['', null, undefined].includes(filteredFilm.year)) {
  1504. titleLinkText += ` (${filteredFilm.year})`;
  1505. }
  1506. filteredTitleLink.innerText = titleLinkText;
  1507.  
  1508. filteredTitleLink.oncontextmenu = (event) => {
  1509. event.preventDefault();
  1510.  
  1511. filteredTitleLink.classList.toggle(SELECTORS.settings.removePendingClass);
  1512. };
  1513. });
  1514.  
  1515. let formColumnsDiv = document.createElement('div');
  1516. filteredFilmsDiv.appendChild(formColumnsDiv);
  1517.  
  1518. formColumnsDiv.classList.add('form-columns', '-cols2');
  1519.  
  1520. // Filter films behavior
  1521. const filmBehaviorsMetadata = {
  1522. fade: {
  1523. fieldName: 'filmBehaviorFadeAmount',
  1524. },
  1525. blur: {
  1526. fieldName: 'filmBehaviorBlurAmount',
  1527. },
  1528. replace: {
  1529. fieldName: 'filmBehaviorReplaceValue',
  1530. labelText: 'Direct image URL',
  1531. },
  1532. custom: {
  1533. fieldName: 'filmBehaviorCustomValue',
  1534. },
  1535. };
  1536. const filmFormRows = buildBehaviorFormRows(
  1537. formColumnsDiv,
  1538. 'film',
  1539. FILM_BEHAVIORS,
  1540. filmBehaviorsMetadata,
  1541. );
  1542.  
  1543. const clearDiv = filteredFilmsDiv.querySelector(SELECTORS.settings.clear);
  1544. clearDiv.remove();
  1545.  
  1546. // Filter reviews
  1547. const filteredReviewsFormRow = document.createElement('div');
  1548. tabPrimaryColumn.append(filteredReviewsFormRow);
  1549.  
  1550. filteredReviewsFormRow.classList.add('form-row');
  1551.  
  1552. const filteredReviewsTitle = document.createElement('h3');
  1553. filteredReviewsFormRow.append(filteredReviewsTitle);
  1554.  
  1555. filteredReviewsTitle.classList.add('title-3');
  1556. filteredReviewsTitle.style.cssText = 'margin-top: 0em;';
  1557. filteredReviewsTitle.innerText = 'Reviews Filter';
  1558.  
  1559. // First unordered list
  1560. const filteredReviewsUnorderedListFirst = document.createElement('ul');
  1561. filteredReviewsFormRow.append(filteredReviewsUnorderedListFirst);
  1562.  
  1563. filteredReviewsUnorderedListFirst.classList.add('options-list', '-toggle-list', 'js-toggle-list');
  1564. filteredReviewsUnorderedListFirst.style.cssText += 'margin-bottom: 5px;';
  1565.  
  1566. const reviewFilterItemsFirst = [
  1567. {
  1568. name: 'ratings',
  1569. description: 'Remove ratings from reviews',
  1570. },
  1571. {
  1572. name: 'likes',
  1573. description: 'Remove likes from reviews',
  1574. },
  1575. {
  1576. name: 'comments',
  1577. description: 'Remove comments from reviews',
  1578. },
  1579. {
  1580. name: 'byWordCount',
  1581. description: 'Filter reviews by minimum word count',
  1582. },
  1583. ];
  1584.  
  1585. buildToggleSectionListItems(
  1586. 'reviewFilter',
  1587. filteredReviewsUnorderedListFirst,
  1588. reviewFilterItemsFirst,
  1589. );
  1590.  
  1591. // Minium word count
  1592. let minimumWordCountDiv = document.createElement('div');
  1593. filteredReviewsFormRow.appendChild(minimumWordCountDiv);
  1594.  
  1595. minimumWordCountDiv.classList.add('form-columns', '-cols2');
  1596.  
  1597. const minimumWordCountValue = gmcGet('reviewMinimumWordCount');
  1598. log(DEBUG, 'minimumWordCountValue', minimumWordCountValue);
  1599.  
  1600. const minimumWordCountFormRow = createFormRow({
  1601. formRowClass: ['update-details'],
  1602. formRowStyle: `width: ${COLUMN_TWO_WIDTH}; float: right; margin-bottom: 10px;`,
  1603. inputValue: minimumWordCountValue,
  1604. inputType: 'number',
  1605. inputStyle: 'width: 100px !important;',
  1606. notes: 'words',
  1607. notesStyle: 'width: 10px; margin-left: 14px;',
  1608. });
  1609.  
  1610. minimumWordCountDiv.appendChild(minimumWordCountFormRow);
  1611.  
  1612. // Second unordered list
  1613. const filteredReviewsUnorderedListSecond = document.createElement('ul');
  1614. filteredReviewsFormRow.append(filteredReviewsUnorderedListSecond);
  1615.  
  1616. filteredReviewsUnorderedListSecond.classList.add('options-list', '-toggle-list', 'js-toggle-list');
  1617. filteredReviewsUnorderedListSecond.style.cssText += 'margin: 0 0 1.53846154rem;';
  1618.  
  1619. const reviewFilterItemsSecond = [
  1620. {
  1621. name: 'withSpoilers',
  1622. description: 'Filter reviews that contain spoilers',
  1623. },
  1624. {
  1625. name: 'withoutRatings',
  1626. description: 'Filter reviews that don\'t have ratings',
  1627. },
  1628. ];
  1629.  
  1630. buildToggleSectionListItems(
  1631. 'reviewFilter',
  1632. filteredReviewsUnorderedListSecond,
  1633. reviewFilterItemsSecond,
  1634. );
  1635.  
  1636. let reviewColumnsDiv = document.createElement('div');
  1637. filteredReviewsFormRow.appendChild(reviewColumnsDiv);
  1638.  
  1639. reviewColumnsDiv.classList.add('form-columns', '-cols2');
  1640.  
  1641. const reviewBehaviorsMetadata = {
  1642. fade: {
  1643. fieldName: 'reviewBehaviorFadeAmount',
  1644. },
  1645. blur: {
  1646. fieldName: 'reviewBehaviorBlurAmount',
  1647. },
  1648. replace: {
  1649. fieldName: 'reviewBehaviorReplaceValue',
  1650. labelText: 'Text',
  1651. },
  1652. custom: {
  1653. fieldName: 'reviewBehaviorCustomValue',
  1654. },
  1655. };
  1656. const reviewFormRows = buildBehaviorFormRows(
  1657. reviewColumnsDiv,
  1658. 'review',
  1659. REVIEW_BEHAVIORS,
  1660. reviewBehaviorsMetadata,
  1661. );
  1662.  
  1663. // Filter homepage
  1664. const homepageFilterMetadata = [
  1665. {
  1666. name: 'friendsHaveBeenWatching',
  1667. description: 'Remove "Here\'s what your friends have been watching..." title text',
  1668. },
  1669. {
  1670. name: 'newFromFriends',
  1671. description: 'Remove "New from friends" films section',
  1672. },
  1673. {
  1674. name: 'popularWithFriends',
  1675. description: 'Remove "Popular with friends" section',
  1676. },
  1677. {
  1678. name: 'discoveryStream',
  1679. description: 'Remove discovery section (e.g. festivals, competitions)',
  1680. },
  1681. {
  1682. name: 'latestNews',
  1683. description: 'Remove "Latest news" section',
  1684. },
  1685. {
  1686. name: 'popularReviewsWithFriends',
  1687. description: 'Remove "Popular reviews with friends" section',
  1688. },
  1689. {
  1690. name: 'newListsFromFriends',
  1691. description: 'Remove "New from friends" lists section',
  1692. },
  1693. {
  1694. name: 'popularLists',
  1695. description: 'Remove "Popular lists" section',
  1696. },
  1697. {
  1698. name: 'recentStories',
  1699. description: 'Remove "Recent stories" section',
  1700. },
  1701. {
  1702. name: 'recentShowdowns',
  1703. description: 'Remove "Recent showdowns" section',
  1704. },
  1705. {
  1706. name: 'recentNews',
  1707. description: 'Remove "Recent news" section',
  1708. },
  1709. ];
  1710.  
  1711. buildToggleSection(
  1712. tabPrimaryColumn,
  1713. 'Homepage Filter',
  1714. 'homepageFilter',
  1715. homepageFilterMetadata,
  1716. );
  1717.  
  1718. // Save changes
  1719. let buttonsRowDiv = document.createElement('div');
  1720. userscriptTabDiv.appendChild(buttonsRowDiv);
  1721.  
  1722. buttonsRowDiv.style.cssText = 'display: flex; align-items: center;';
  1723. buttonsRowDiv.classList.add('buttons', 'clear', 'row');
  1724.  
  1725. let saveInput = document.createElement('input');
  1726. buttonsRowDiv.appendChild(saveInput);
  1727.  
  1728. saveInput.classList.add('button', 'button-action');
  1729. saveInput.setAttribute('value', 'Save Changes');
  1730. saveInput.setAttribute('type', 'submit');
  1731. saveInput.onclick = (event) => {
  1732. event.preventDefault();
  1733.  
  1734. const pendingRemovals = hiddenTitlesParagraph.querySelectorAll(`.${SELECTORS.settings.removePendingClass}`);
  1735. pendingRemovals.forEach(removalLink => {
  1736. const id = parseInt(removalLink.getAttribute('data-film-id'));
  1737. const filteredFilm = filmFilter.find(filteredFilm => filteredFilm.id === id);
  1738.  
  1739. if (filteredFilm) {
  1740. removeFilterFromFilm(filteredFilm);
  1741. removeFromFilmFilter(filteredFilm);
  1742. } else {
  1743. const index = removalLink.getAttribute('index');
  1744. removeFromFilmFilter(null, index);
  1745. }
  1746. removalLink.remove();
  1747. });
  1748.  
  1749. const minimumWordCountValue = parseInt(minimumWordCountFormRow.querySelector('input').value || 0);
  1750. log(DEBUG, 'minimumWordCountValue', minimumWordCountValue);
  1751.  
  1752. gmcSet('reviewMinimumWordCount', minimumWordCountValue);
  1753.  
  1754. saveBehaviorSettings('film', filmFormRows);
  1755. saveBehaviorSettings('review', reviewFormRows);
  1756.  
  1757. const inputToggles = userscriptTabDiv.querySelectorAll('input[type="checkbox"]');
  1758. inputToggles.forEach(inputToggle => {
  1759. const filterName = inputToggle.getAttribute('data-filter-name');
  1760. const filter = getFilter(filterName);
  1761.  
  1762. const fieldName = inputToggle.getAttribute('data-field-name');
  1763. const checked = inputToggle.checked;
  1764.  
  1765. filter[fieldName] = checked;
  1766. setFilter(filterName, filter);
  1767. });
  1768.  
  1769. const logLevel = logLevelFormRow.querySelector('select').value;
  1770. gmcSet('logLevel', logLevel);
  1771.  
  1772. const maxIdleMutationsValue = parseInt(maxIdleMutationsFormRow.querySelector('input').value || 0);
  1773. log(DEBUG, 'maxIdleMutationsValue', maxIdleMutationsValue);
  1774.  
  1775. gmcSet('maxIdleMutations', maxIdleMutationsValue);
  1776.  
  1777. const maxActiveMutationsValue = parseInt(maxActiveMutationsFormRow.querySelector('input').value || 0);
  1778. log(DEBUG, 'maxActiveMutationsValue', maxActiveMutationsValue);
  1779.  
  1780. gmcSet('maxActiveMutations', maxActiveMutationsValue);
  1781.  
  1782. gmcSave();
  1783.  
  1784. displaySavedBadge();
  1785. };
  1786.  
  1787. let checkContainerDiv = document.createElement('div');
  1788. buttonsRowDiv.appendChild(checkContainerDiv);
  1789.  
  1790. checkContainerDiv.classList.add('check-container');
  1791. checkContainerDiv.style.cssText = 'margin-left: 10px;';
  1792.  
  1793. let usernameAvailableParagraph = document.createElement('p');
  1794. checkContainerDiv.appendChild(usernameAvailableParagraph);
  1795.  
  1796. usernameAvailableParagraph.classList.add(
  1797. 'username-available',
  1798. 'has-icon',
  1799. 'hidden',
  1800. SELECTORS.settings.savedBadgeClass,
  1801. );
  1802. usernameAvailableParagraph.style.cssText = 'float: left;';
  1803.  
  1804. let iconSpan = document.createElement('span');
  1805. usernameAvailableParagraph.appendChild(iconSpan);
  1806.  
  1807. iconSpan.classList.add('icon');
  1808.  
  1809. const savedText = document.createTextNode('Saved');
  1810. usernameAvailableParagraph.appendChild(savedText);
  1811.  
  1812. const settingsSubNav = document.querySelector(SELECTORS.settings.subNav);
  1813.  
  1814. const userscriptSubNabListItem = document.createElement('li');
  1815. settingsSubNav.appendChild(userscriptSubNabListItem);
  1816.  
  1817. const userscriptSubNabLink = document.createElement('a');
  1818. userscriptSubNabListItem.appendChild(userscriptSubNabLink);
  1819.  
  1820. const userscriptSettingsLink = '/settings/?filterboxd';
  1821. userscriptSubNabLink.setAttribute('href', userscriptSettingsLink);
  1822. userscriptSubNabLink.setAttribute('data-id', 'filterboxd');
  1823. userscriptSubNabLink.innerText = 'Filterboxd';
  1824. userscriptSubNabLink.onclick = (event) => {
  1825. event.preventDefault();
  1826.  
  1827. Array.from(settingsSubNav.children).forEach(listItem => {
  1828. const link = listItem.querySelector('a');
  1829.  
  1830. if (link.getAttribute('data-id') === 'filterboxd') {
  1831. listItem.classList.add('selected');
  1832. } else {
  1833. listItem.classList.remove('selected');
  1834. }
  1835. });
  1836.  
  1837. Array.from(settingsTabbedContent.children).forEach(tab => {
  1838. if (!tab.id) return;
  1839.  
  1840. const display = tab.id === userscriptTabId ? 'block' : 'none';
  1841. tab.style.cssText = `display: ${display};`;
  1842. });
  1843.  
  1844. window.history.replaceState(null, '', `https://letterboxd.com${userscriptSettingsLink}`);
  1845. };
  1846.  
  1847. Array.from(settingsSubNav.children).forEach(listItem => {
  1848. listItem.onclick = (event) => {
  1849. const link = event.target;
  1850. if (link.getAttribute('href') === userscriptSettingsLink) return;
  1851.  
  1852. userscriptSubNabListItem.classList.remove('selected');
  1853. userscriptTabDiv.style.display = 'none';
  1854. };
  1855. });
  1856. }
  1857.  
  1858. function maybeAddListItemToSidebar() {
  1859. log(DEBUG, 'maybeAddListItemToSidebar()');
  1860.  
  1861. const isListPage = document.querySelector('body.list-page');
  1862. if (isListPage) return;
  1863.  
  1864. const userscriptListItemFound = document.querySelector(createId(SELECTORS.userpanel.userscriptListItemId));
  1865. if (userscriptListItemFound) {
  1866. log(DEBUG, 'Userscript list item already exists');
  1867. return false;
  1868. }
  1869.  
  1870. const userpanel = document.querySelector(SELECTORS.userpanel.self);
  1871.  
  1872. if (!userpanel) {
  1873. log(INFO, 'Userpanel not found');
  1874. return false;
  1875. }
  1876.  
  1877. const secondLastListItem = userpanel.querySelector('li:nth-last-child(3)');
  1878. if (!secondLastListItem ) {
  1879. log(INFO, 'Second last list item not found');
  1880. return false;
  1881. }
  1882.  
  1883. if (secondLastListItem.classList.contains('loading-csi')) {
  1884. log(INFO, 'Second last list item is loading');
  1885. return false;
  1886. }
  1887.  
  1888. let userscriptListItem = document.createElement('li');
  1889. const userscriptListLink = document.createElement('a');
  1890. userscriptListItem.appendChild(userscriptListLink);
  1891. userscriptListLink.href = '#';
  1892.  
  1893. userscriptListItem.setAttribute('id', SELECTORS.userpanel.userscriptListItemId);
  1894.  
  1895. const unorderedList = userpanel.querySelector('ul');
  1896. userscriptListItem = buildUserscriptLink(userscriptListItem, unorderedList);
  1897.  
  1898. // Text: "Go PATRON to change images"
  1899. const upsellLink = userpanel.querySelector('[href="/pro/"]');
  1900.  
  1901. // If the upsell link is present, insert above
  1902. // Otherwise, inset above "Share"
  1903. const insertBeforeElementIndex = upsellLink ? 2 : 1;
  1904. const insertBeforeElement = userpanel.querySelector(`li:nth-last-of-type(${insertBeforeElementIndex})`);
  1905.  
  1906. secondLastListItem.parentNode.insertBefore(userscriptListItem, insertBeforeElement);
  1907.  
  1908. return true;
  1909. }
  1910.  
  1911. function removeFilterFromElement(element, levelsUp = 0) {
  1912. log(DEBUG, 'removeFilterFromElement()');
  1913.  
  1914. const replaceBehavior = gmcGet('filmBehaviorType') === 'Replace poster';
  1915. log(VERBOSE, 'replaceBehavior', replaceBehavior);
  1916.  
  1917. if (replaceBehavior) {
  1918. const originalImgSrc = element.getAttribute('data-original-img-src');
  1919. if (!originalImgSrc) {
  1920. log(DEBUG, 'data-original-img-src attribute not found', element);
  1921. return;
  1922. }
  1923.  
  1924. element.querySelector('img').src = originalImgSrc;
  1925. element.querySelector('img').srcset = originalImgSrc;
  1926.  
  1927. element.removeAttribute('data-original-img-src');
  1928. element.classList.add(SELECTORS.processedClass.remove);
  1929. element.classList.remove(SELECTORS.processedClass.apply);
  1930. } else {
  1931. let target = element;
  1932.  
  1933. for (let i = 0; i < levelsUp; i++) {
  1934. if (target.parentNode) {
  1935. target = target.parentNode;
  1936. } else {
  1937. break;
  1938. }
  1939. }
  1940.  
  1941. log(VERBOSE, 'target', target);
  1942.  
  1943. target.classList.remove(SELECTORS.filter.filmClass);
  1944. element.classList.add(SELECTORS.processedClass.remove);
  1945. element.classList.remove(SELECTORS.processedClass.apply);
  1946. }
  1947. }
  1948.  
  1949. function removeFromFilmFilter(filmMetadata, index) {
  1950. log(DEBUG, 'removeFromFilmFilter()');
  1951.  
  1952. let filmFilter = getFilter('filmFilter');
  1953. if (filmMetadata) {
  1954. filmFilter = filmFilter.filter(filteredFilm => filteredFilm.id !== filmMetadata.id);
  1955. } else {
  1956. filmFilter.splice(index, 1);
  1957. }
  1958.  
  1959. setFilter('filmFilter', filmFilter);
  1960. }
  1961.  
  1962. function removeFilterFromFilm({ id, slug }) {
  1963. log(DEBUG, 'removeFilterFromFilm()');
  1964.  
  1965. const idMatch = `[data-film-id="${id}"]`;
  1966. let removedSelector = `.${SELECTORS.processedClass.remove}`;
  1967.  
  1968. log(VERBOSE, 'Activity page reviews');
  1969. document.querySelectorAll(`section.activity-row ${idMatch}`).forEach(posterElement => {
  1970. removeFilterFromElement(posterElement, 3);
  1971. });
  1972.  
  1973. log(VERBOSE, 'Activity page likes');
  1974. document.querySelectorAll(`section.activity-row .activity-summary a[href*="${slug}"]:not(${removedSelector})`).forEach(posterElement => {
  1975. removeFilterFromElement(posterElement, 3);
  1976. });
  1977.  
  1978. log(VERBOSE, 'New from friends');
  1979. document.querySelectorAll(`.poster-container ${idMatch}:not(${removedSelector})`).forEach(posterElement => {
  1980. removeFilterFromElement(posterElement, 1);
  1981. });
  1982.  
  1983. log(VERBOSE, 'Reviews');
  1984. document.querySelectorAll(`.review-tile ${idMatch}:not(${removedSelector})`).forEach(posterElement => {
  1985. removeFilterFromElement(posterElement, 3);
  1986. });
  1987.  
  1988. log(VERBOSE, 'Diary');
  1989. document.querySelectorAll(`.td-film-details [data-original-img-src]${idMatch}:not(${removedSelector})`).forEach(posterElement => {
  1990. removeFilterFromElement(posterElement, 2);
  1991. });
  1992.  
  1993. log(VERBOSE, 'Popular with friends, competitions');
  1994. const remainingElements = document.querySelectorAll(
  1995. `div:not(.popmenu):not(.actions-panel) ${idMatch}:not(aside [data-film-id="${id}"]):not(#backdrop):not(${removedSelector})`,
  1996. );
  1997. remainingElements.forEach(posterElement => {
  1998. removeFilterFromElement(posterElement, 0);
  1999. });
  2000. }
  2001.  
  2002. function saveBehaviorSettings(filterName, formRows) {
  2003. log(DEBUG, 'saveBehaviorSettings()');
  2004.  
  2005. const behaviorType = formRows[0].querySelector('select').value;
  2006. log(DEBUG, 'behaviorType', behaviorType);
  2007.  
  2008. gmcSet(`${filterName}BehaviorType`, behaviorType);
  2009.  
  2010. updateBehaviorCSSVariables(filterName, behaviorType);
  2011.  
  2012. if (behaviorType === 'Fade') {
  2013. const behaviorFadeAmount = formRows[1].querySelector('input').value;
  2014. log(DEBUG, 'behaviorFadeAmount', behaviorFadeAmount);
  2015.  
  2016. gmcSet(`${filterName}BehaviorFadeAmount`, behaviorFadeAmount);
  2017. } else if (behaviorType === 'Blur') {
  2018. const behaviorBlurAmount = formRows[2].querySelector('input').value;
  2019. log(DEBUG, 'behaviorBlurAmount', behaviorBlurAmount);
  2020.  
  2021. gmcSet(`${filterName}BehaviorBlurAmount`, behaviorBlurAmount);
  2022. } else if (behaviorType.includes('Replace')) {
  2023. const behaviorReplaceValue = formRows[3].querySelector('input').value;
  2024. log(DEBUG, 'behaviorReplaceValue', behaviorReplaceValue);
  2025.  
  2026. gmcSet(`${filterName}BehaviorReplaceValue`, behaviorReplaceValue);
  2027. } else if (behaviorType === 'Custom') {
  2028. const behaviorCustomValue = formRows[4].querySelector('input').value;
  2029. log(DEBUG, 'behaviorCustomValue', behaviorCustomValue);
  2030.  
  2031. gmcSet(`${filterName}BehaviorCustomValue`, behaviorCustomValue);
  2032. }
  2033. }
  2034.  
  2035. function setFilter(filterName, filterValue) {
  2036. log(DEBUG, 'setFilter()');
  2037.  
  2038. gmcSet(filterName, JSON.stringify(filterValue));
  2039. return gmcSave();
  2040. }
  2041.  
  2042. function updateBehaviorCSSVariables(filterName, behaviorType) {
  2043. log(DEBUG, 'updateBehaviorTypeVariable()');
  2044. log(DEBUG, 'behaviorType', behaviorType);
  2045.  
  2046. const fadeValue = behaviorType === 'Fade' ? 'block' : 'none';
  2047. document.documentElement.style.setProperty(
  2048. `--filterboxd-${filterName}-behavior-fade`,
  2049. fadeValue,
  2050. );
  2051.  
  2052. const blurValue = behaviorType === 'Blur' ? 'block' : 'none';
  2053. document.documentElement.style.setProperty(
  2054. `--filterboxd-${filterName}-behavior-blur`,
  2055. blurValue,
  2056. );
  2057.  
  2058. const replaceValue = behaviorType.includes('Replace') ? 'block' : 'none';
  2059. document.documentElement.style.setProperty(
  2060. `--filterboxd-${filterName}-behavior-replace`,
  2061. replaceValue,
  2062. );
  2063.  
  2064. const customValue = behaviorType === 'Custom' ? 'block' : 'none';
  2065. document.documentElement.style.setProperty(
  2066. `--filterboxd-${filterName}-behavior-custom`,
  2067. customValue,
  2068. );
  2069. }
  2070.  
  2071. function updateLinkInPopMenu(titleIsHidden, link) {
  2072. log(DEBUG, 'updateLinkInPopMenu()');
  2073.  
  2074. link.setAttribute('data-title-hidden', titleIsHidden);
  2075.  
  2076. const innerText = titleIsHidden ? 'Remove from filter' : 'Add to filter';
  2077. link.innerText = innerText;
  2078. }
  2079.  
  2080. const urlParams = new URLSearchParams(window.location.search);
  2081. const tabSelected = urlParams.get('filterboxd') !== null;
  2082. log(DEBUG, 'tabSelected', tabSelected);
  2083.  
  2084. if (tabSelected) trySelectFilterboxdTab();
  2085.  
  2086. let OBSERVER = new MutationObserver(observeAndModify);
  2087.  
  2088. const GMC_FIELDS = {
  2089. filmBehaviorType: {
  2090. type: 'select',
  2091. options: FILM_BEHAVIORS,
  2092. default: 'Fade',
  2093. },
  2094. filmBehaviorBlurAmount: {
  2095. type: 'int',
  2096. default: 3,
  2097. },
  2098. filmBehaviorCustomValue: {
  2099. type: 'text',
  2100. default: '',
  2101. },
  2102. filmBehaviorFadeAmount: {
  2103. type: 'int',
  2104. default: 10,
  2105. },
  2106. filmBehaviorReplaceValue: {
  2107. type: 'text',
  2108. default: 'https://raw.githubusercontent.com/blakegearin/filterboxd/main/img/bee-movie.jpg',
  2109. },
  2110. filmFilter: {
  2111. type: 'text',
  2112. default: JSON.stringify([]),
  2113. },
  2114. filmPageFilter: {
  2115. type: 'text',
  2116. default: JSON.stringify({}),
  2117. },
  2118. homepageFilter: {
  2119. type: 'text',
  2120. default: JSON.stringify({}),
  2121. },
  2122. logLevel: {
  2123. type: 'select',
  2124. options: LOG_LEVELS.options,
  2125. default: LOG_LEVELS.default,
  2126. },
  2127. reviewBehaviorType: {
  2128. type: 'select',
  2129. options: REVIEW_BEHAVIORS,
  2130. default: 'Fade',
  2131. },
  2132. reviewBehaviorBlurAmount: {
  2133. type: 'int',
  2134. default: 3,
  2135. },
  2136. reviewBehaviorCustomValue: {
  2137. type: 'text',
  2138. default: '',
  2139. },
  2140. reviewBehaviorFadeAmount: {
  2141. type: 'int',
  2142. default: 10,
  2143. },
  2144. reviewBehaviorReplaceValue: {
  2145. type: 'text',
  2146. default: 'According to all known laws of aviation, there is no way a bee should be able to fly.',
  2147. },
  2148. reviewFilter: {
  2149. type: 'text',
  2150. default: JSON.stringify({}),
  2151. },
  2152. reviewMinimumWordCount: {
  2153. type: 'int',
  2154. default: 10,
  2155. },
  2156. maxIdleMutations: {
  2157. type: 'int',
  2158. default: 10000,
  2159. },
  2160. maxActiveMutations: {
  2161. type: 'int',
  2162. default: 10000,
  2163. },
  2164. };
  2165.  
  2166. GMC = new GM_config({
  2167. id: 'gmc-frame',
  2168. events: {
  2169. init: gmcInitialized,
  2170. },
  2171. fields: GMC_FIELDS,
  2172. });
  2173. })();