├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── pack.bat ├── screenshots ├── 1.png ├── 2.png ├── 3.png ├── 4.png ├── 5.png ├── 6.png ├── promo.png └── ratings-logos.png └── src ├── build chrome.lnk ├── build firefox.lnk ├── build.bat ├── chrome ├── colour_scheme.css └── manifest.json ├── common ├── background.js ├── google2letterboxd.js ├── icon128.png ├── icon16.png ├── icon32.png ├── icon512.png ├── images │ ├── bfi-logo.svg │ ├── filmarks-logo.svg │ ├── kinopoisk-logo-eng.svg │ ├── kinopoisk-logo-rus.svg │ ├── mal-logo.png │ ├── sens-logo.png │ ├── simkl-logo.png │ ├── tomato-audience-hot.svg │ ├── tomato-audience-no-score.svg │ ├── tomato-audience-stale.svg │ ├── tomato-audience-verified-hot.svg │ ├── tomato-critic-certified-fresh.svg │ ├── tomato-critic-fresh.svg │ ├── tomato-critic-no-score.svg │ └── tomato-critic-rotten.svg ├── letterboxd-extras.user.js ├── options.css ├── options.html ├── options.js ├── package.json └── polyfill.js ├── firefox ├── colour_scheme.css ├── manifest.json └── restore.html ├── pack chrome.lnk ├── pack firefox.lnk └── pack.bat /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | Letterboxd-Extras.zip 3 | ff android debug.txt 4 | /src/dist 5 | *.zip 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 duncanlang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Letterboxd-Extras 2 | 3 | A browser add-on/extension that adds additional features to the movie tracking database site [Letterboxd](https://letterboxd.com/) including ratings from other sites, box office and budget, and more. 4 | 5 | Get Letterboxd Extras for Firefox 6 | Get Letterboxd Extras for Chromium 7 | Get Letterboxd Extras for Microsoft Edge 8 | 9 | **This add-on also supports the mobile Letterboxd website and has been tested to work on Firefox for Android.** Other Android browsers that support add-ons/extensions should work, but have not been tested. 10 | 11 | ## Features 12 | - Film ratings: displays film ratings from other websites, by default: 13 | - IMDb 14 | - Rotten Tomatoes 15 | - Metacritic 16 | - MyAnimeList 17 | - AniList 18 | - CinemaScore 19 | - Additional film rating sites, disabled by default: 20 | - SensCritique 21 | - MUBI 22 | - FilmAffinity 23 | - Simkl 24 | - AlloCiné 25 | - Additional top film rankings: Display the rankings from "They Shoot Pictures, Don't They?" top 1000 and "BFI Sight and Sound" top 250 26 | - Info box on people pages: A Wikipedia-like info box on people pages for the birth dates, death dates, and years active 27 | - MPA film ratings: Display the film's MPA rating 28 | - Wide release date: Display the full wide release date on hover of the film year 29 | - Duration: Converts the duration to hours and minutes on hover of the duration 30 | - Budget and Box Office: Display budget and box office numbers in the details tabs 31 | - Search: option to default the search to filter to films only 32 | 33 | 34 | ## Installation 35 | ### Firefox 36 | [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/letterboxd-extras/) 37 | 38 | XPI file for manual installation available for each release on the [releases tab](https://github.com/duncanlang/Letterboxd-Extras/releases). 39 | 40 | ### Chromium 41 | [Chrome Web Store](https://chromewebstore.google.com/detail/letterboxd-extras/edhldpamlnkpekapihiolppcdppgeice) 42 | 43 | [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/letterboxd-extras/khnodkkceaakcafenlmnbbjgfkhjmbgh) 44 | 45 | 46 | ## Issues or Suggestions 47 | Any issues or suggestions, please [create an issue on Github](https://github.com/duncanlang/Letterboxd-Extras/issues). 48 | 49 | Any suggestion for new rating sites will be considered, but may not be possible due to lack of APIs or anti-scraping rules. 50 | 51 | ## Screenshots 52 | Additional ratings from IMDb, Rotten Tomatoes, Metacritic, and Cinemascore in the sidebar, new links at the bottom, MPA rating at the top 53 | Ratings, and MPA rating 54 | 55 | Extra details for Rotten Tomatoes and Metacritic expanded in the sidebar 56 | Rotten Tomatoes and Metacritc additional details 57 | 58 | More ratings from SensCritique, MUBI, filmaffinity, and SIMKL, rankings on the left sidebar, and budget and box office in the details tab 59 | More ratings and rankings 60 | 61 | More ratings from Allocine 62 | Allocine ratings 63 | 64 | MyAnimeList and AniList ratings for Anime 65 | MyAnimeList and AniList 66 | 67 | Info box below photo of the actor, displaying the birth and death dates as well as the years active 68 | Actor info box 69 | -------------------------------------------------------------------------------- /pack.bat: -------------------------------------------------------------------------------- 1 | "C:\Program Files\7-Zip\7z.exe" a -tzip Letterboxd-Extras.zip images/* icon16.png icon32.png icon128.png jquery-3.6.0.min.js letterboxd-extras.user.js manifest.json options.html options.js package.json polyfill.js background.js google2letterboxd.js restore.html -------------------------------------------------------------------------------- /screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/screenshots/1.png -------------------------------------------------------------------------------- /screenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/screenshots/2.png -------------------------------------------------------------------------------- /screenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/screenshots/3.png -------------------------------------------------------------------------------- /screenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/screenshots/4.png -------------------------------------------------------------------------------- /screenshots/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/screenshots/5.png -------------------------------------------------------------------------------- /screenshots/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/screenshots/6.png -------------------------------------------------------------------------------- /screenshots/promo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/screenshots/promo.png -------------------------------------------------------------------------------- /screenshots/ratings-logos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/screenshots/ratings-logos.png -------------------------------------------------------------------------------- /src/build chrome.lnk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/src/build chrome.lnk -------------------------------------------------------------------------------- /src/build firefox.lnk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/src/build firefox.lnk -------------------------------------------------------------------------------- /src/build.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal enabledelayedexpansion 3 | 4 | :: Ask for target if not passed 5 | if "%1"=="" ( 6 | echo Usage: build.bat chrome ^| firefox 7 | exit /b 1 8 | ) 9 | 10 | set TARGET=%1 11 | set SOURCE_DIR=%~dp0 12 | set COMMON_DIR=%SOURCE_DIR%common 13 | set TARGET_DIR=%SOURCE_DIR%%TARGET% 14 | set DIST_DIR=%SOURCE_DIR%dist\%TARGET% 15 | 16 | :: Clean previous build 17 | if exist "%DIST_DIR%" ( 18 | rmdir /s /q "%DIST_DIR%" 19 | ) 20 | mkdir "%DIST_DIR%" 21 | 22 | :: Copy common files 23 | xcopy /e /i /y "%COMMON_DIR%" "%DIST_DIR%" 24 | 25 | :: Copy manifest 26 | copy /y "%TARGET_DIR%" "%DIST_DIR%" 27 | 28 | echo Build complete: %DIST_DIR% -------------------------------------------------------------------------------- /src/chrome/colour_scheme.css: -------------------------------------------------------------------------------- 1 | @media (prefers-color-scheme: light) { 2 | body { 3 | --body-text-color: #000; 4 | --body-back-color: #ffffff; 5 | --body-text-alt-color: #7d7d7d; 6 | } 7 | } 8 | 9 | @media (prefers-color-scheme: dark) { 10 | body { 11 | --body-text-color: #f4f4f4; 12 | --body-back-color: rgb(32, 33, 36); /* --google-grey-900 */ 13 | --body-text-alt-color: #a3a2a7; 14 | } 15 | } -------------------------------------------------------------------------------- /src/chrome/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Letterboxd Extras", 4 | "description": "Displays additional scores on Letterboxd (IMDB, Rotten Tomatoes, Metacritic).", 5 | "version": "3.17.0", 6 | "minimum_chrome_version": "88", 7 | 8 | "action": { 9 | "default_title": "Letterboxd Extras Settings", 10 | "default_popup": "options.html?type=action", 11 | "default_icon": { 12 | "16": "icon16.png", 13 | "32": "icon32.png", 14 | "128": "icon128.png" 15 | } 16 | }, 17 | 18 | "icons": { 19 | "128": "icon128.png" 20 | }, 21 | 22 | "options_ui": { 23 | "page": "options.html", 24 | "browser_style": true, 25 | "open_in_tab": true 26 | }, 27 | 28 | "permissions": [ 29 | "storage", 30 | "scripting" 31 | ], 32 | 33 | "host_permissions": [ 34 | "https://www.imdb.com/*", 35 | "https://letterboxd.com/*", 36 | "https://*.imdb.com/*", 37 | "https://www.rottentomatoes.com/*", 38 | "https://www.boxofficemojo.com/*", 39 | "https://webapp.cinemascore.com/*", 40 | "https://query.wikidata.org/*", 41 | "https://www.metacritic.com/*", 42 | "https://api.jikan.moe/*", 43 | "https://graphql.anilist.co/*" 44 | ], 45 | 46 | "optional_host_permissions": [ 47 | "https://api.mubi.com/*", 48 | "https://apollo.senscritique.com/*", 49 | "https://*.filmaffinity.com/*", 50 | "https://www.theyshootpictures.com/*", 51 | "https://www.bfi.org.uk/*", 52 | "https://www.allocine.fr/*", 53 | "https://api.simkl.com/*", 54 | "https://www.google.com/search*", 55 | "https://www.doesthedogdie.com/*", 56 | "https://markuapi.apn.leapcell.app/*", 57 | "https://kinopoiskapiunofficial.tech/api/*" 58 | ], 59 | 60 | "background": { 61 | "service_worker": "background.js", 62 | "type": "module" 63 | }, 64 | 65 | "content_scripts": [ 66 | { 67 | "matches": [ "https://letterboxd.com/*" ], 68 | "js": [ "polyfill.js", "letterboxd-extras.user.js" ], 69 | "run_at": "document_start" 70 | } 71 | ], 72 | 73 | "web_accessible_resources": [ 74 | { 75 | "resources": [ "images/*"], 76 | "matches": [ "https://letterboxd.com/*" ], 77 | "use_dynamic_url": true 78 | } 79 | ] 80 | } 81 | -------------------------------------------------------------------------------- /src/common/background.js: -------------------------------------------------------------------------------- 1 | const isFirefox = typeof browser !== "undefined" && typeof browser.runtime !== "undefined"; 2 | const isChrome = typeof chrome !== "undefined" && typeof browser === "undefined"; 3 | 4 | 5 | if (isChrome) 6 | var browser = chrome; 7 | 8 | browser.runtime.onMessage.addListener((msg, sender, response) => { 9 | // Logging 10 | browser.storage.sync.get('options', (data) => { 11 | if (data != null){ 12 | var options = data.options; 13 | if (options != null && options.hasOwnProperty('console-log') && options['console-log'] == true) { 14 | console.log("Letterboxd Extras | " + msg.url); 15 | } 16 | } 17 | }); 18 | 19 | if (msg.type == null){ 20 | msg.type = ""; 21 | } 22 | 23 | if (msg.name == "GETDATA") { // Standard call 24 | var options = null; 25 | if (msg.options != null) 26 | options = msg.options; 27 | 28 | if (msg.url.includes('kinopoiskapiunofficial.tech') && msg.options == null){ 29 | // do not steal pls :( 30 | var options = { 31 | method: 'GET', 32 | headers: { 33 | 'X-API-KEY': 'd761642c-8182-4167-b8db-cae260ade0db' 34 | } 35 | }; 36 | } 37 | 38 | try { 39 | (async () => { 40 | // Check for permission before call 41 | if (await CheckForPermission(msg.url)){ 42 | fetch(encodeURI(msg.url), options).then(async function (res) { 43 | var errors = null; 44 | 45 | // Check for errors 46 | if (res.status !== 200) { 47 | if (res.errors != null) 48 | errors = res.errors; 49 | 50 | response({ response: null, url: null, status: res.status, errors: errors }) 51 | return; 52 | } 53 | 54 | // Get response body 55 | var resData = null; 56 | if (msg.type == "JSON"){ 57 | await res.json().then(function (data) { 58 | resData = data; 59 | }); 60 | }else{ 61 | await res.text().then(function (data) { 62 | resData = data; 63 | }); 64 | } 65 | 66 | response({ response: resData, url: res.url, status: res.status }); 67 | }); 68 | }else{ 69 | response({ response: null, url: msg.url, status: 0, errors: ["No permission found matching url: " + msg.url] }); 70 | } 71 | })(); 72 | } catch (exception){ 73 | response({ response: null, url: null, status: 0 }) 74 | } 75 | 76 | } else if (msg.name == "RESETSETTINGS") { // Reset saved settings 77 | return (async () => { 78 | var options = {}; 79 | await browser.storage.sync.set({ options }); 80 | await InitDefaultSettings(); 81 | return true; 82 | })(); 83 | 84 | } else if (msg.name == "GETPERMISSIONS") { // Get all permissions 85 | return (async () => { 86 | var permissions = await browser.permissions.getAll(); 87 | return permissions; 88 | })(); 89 | } 90 | 91 | return true; 92 | }); 93 | 94 | async function registerContentScripts() { 95 | await browser.storage.sync.get('options', async (data) => { 96 | if (data != null){ 97 | var storedSettings = data.options; 98 | if (storedSettings != null && storedSettings.hasOwnProperty("google") && storedSettings["google"] === true) { 99 | const script = { 100 | id: 'google2letterboxd', 101 | js: ['google2letterboxd.js'], 102 | matches: ['https://www.google.com/search*'], 103 | }; 104 | await browser.scripting.registerContentScripts([script]).catch(console.error); 105 | } 106 | } 107 | }); 108 | } 109 | 110 | // InitDefaultSettings - Run every update/install to make sure all settings are initilized 111 | async function InitDefaultSettings() { 112 | // Get options from sync 113 | var options = {}; 114 | const data = await browser.storage.sync.get('options'); 115 | if (data != null && data.options != null) { 116 | Object.assign(options, data.options); 117 | } 118 | 119 | if (options == null) 120 | options = {}; 121 | 122 | // mpa-enabled -> content-ratings 123 | if (options['mpa-enabled'] != null){ 124 | if (options['mpa-enabled'] === true){ 125 | options['content-ratings'] = 'mpaa'; 126 | }else{ 127 | options['content-ratings'] = 'none'; 128 | } 129 | options['mpa-enabled'] = null; 130 | } 131 | 132 | // Default enabled settings 133 | if (options['imdb-enabled'] == null) options['imdb-enabled'] = true; 134 | if (options['tomato-enabled'] == null) options['tomato-enabled'] = true; 135 | if (options['metacritic-enabled'] == null) options['metacritic-enabled'] = true; 136 | if (options['mal-enabled'] == null) options['mal-enabled'] = true; 137 | if (options['al-enabled'] == null) options['al-enabled'] = true; 138 | if (options['cinema-enabled'] == null) options['cinema-enabled'] = true; 139 | if (options['mojo-link-enabled'] == null) options['mojo-link-enabled'] = true; 140 | if (options['wiki-link-enabled'] == null) options['wiki-link-enabled'] = true; 141 | if (options['tomato-critic-enabled'] == null) options['tomato-critic-enabled'] = true; 142 | if (options['tomato-audience-enabled'] == null) options['tomato-audience-enabled'] = true; 143 | if (options['metacritic-critic-enabled'] == null) options['metacritic-critic-enabled'] = true; 144 | if (options['metacritic-users-enabled'] == null) options['metacritic-users-enabled'] = true; 145 | if (options['metacritic-mustsee-enabled'] == null) options['metacritic-mustsee-enabled'] = true; 146 | if (options['sens-favorites-enabled'] == null) options['sens-favorites-enabled'] = true; 147 | if (options['allocine-critic-enabled'] == null) options['allocine-critic-enabled'] = true; 148 | if (options['allocine-users-enabled'] == null) options['allocine-users-enabled'] = true; 149 | if (options['content-ratings'] == null) options['content-ratings'] = 'mpaa'; 150 | 151 | // Default disabled settings 152 | if (options['rt-default-view'] == null) options['rt-default-view'] = "hide"; 153 | if (options['critic-default'] == null) options['critic-default'] = "all"; 154 | if (options['audience-default'] == null) options['audience-default'] = "all"; 155 | if (options['meta-default-view'] == null) options['meta-default-view'] = "hide"; 156 | if (options['senscritique-enabled'] == null) options['senscritique-enabled'] = false; 157 | if (options['mubi-enabled'] == null) options['mubi-enabled'] = false; 158 | if (options['filmaff-enabled'] == null) options['filmaff-enabled'] = false; 159 | if (options['simkl-enabled'] == null) options['simkl-enabled'] = false; 160 | if (options['allocine-enabled'] == null) options['allocine-enabled'] = false; 161 | if (options['allocine-default-view'] == null) options['allocine-default-view'] = "user"; 162 | if (options['search-redirect'] == null) options['search-redirect'] = false; 163 | if (options['tspdt-enabled'] == null) options['tspdt-enabled'] = false; 164 | if (options['bfi-enabled'] == null) options['bfi-enabled'] = false; 165 | if (options['convert-ratings'] == null) options['convert-ratings'] = "false"; 166 | if (options['mpa-convert'] == null) options['mpa-convert'] = false; 167 | if (options['open-same-tab'] == null) options['open-same-tab'] = false; 168 | if (options['replace-fans'] == null) options['replace-fans'] = "false"; 169 | if (options['hide-ratings-enabled'] == null) options['hide-ratings-enabled'] = false; 170 | if (options['tooltip-show-details'] == null) options['tooltip-show-details'] = false; 171 | if (options['google'] == null) options['google'] = false; 172 | if (options['boxoffice-enabled'] == null) options['boxoffice-enabled'] = false; 173 | if (options['ddd-enabled'] == null) options['ddd-enabled'] = false; 174 | if (options['ddd-apikey'] == null) options['ddd-apikey'] = ''; 175 | if (options['kinopoisk-enabled'] == null) options['kinopoisk-enabled'] = false; 176 | if (options['kinopoisk-apikey'] == null) options['kinopoisk-apikey'] = ''; 177 | if (options['filmarks-enabled'] == null) options['filmarks-enabled'] = false; 178 | 179 | if (options["convert-ratings"] === true) { 180 | options["convert-ratings"] = "5"; 181 | } 182 | 183 | 184 | // Save 185 | await browser.storage.sync.set({ options }); 186 | } 187 | 188 | async function InitLocalStorage(){ 189 | // Get options from sync 190 | var options = {}; 191 | const data = await browser.storage.local.get('options'); 192 | if (data != null && data.options != null) { 193 | Object.assign(options, data.options); 194 | } 195 | 196 | if (options['hide-lost-films'] == null) options['hide-lost-films'] = 'show'; 197 | 198 | // Save 199 | await browser.storage.local.set({ options }); 200 | } 201 | 202 | // Convert storage.local to storage.sync (Firefox) 203 | async function ConvertLocalToSync() { 204 | // Get from local 205 | var options = await browser.storage.local.get().then(function (storedSettings) { 206 | return storedSettings; 207 | }); 208 | 209 | // Clear the (now) unused local storage 210 | await browser.storage.local.clear(); 211 | 212 | // Save 213 | await browser.storage.sync.set({ options }); 214 | } 215 | 216 | browser.runtime.onStartup.addListener(registerContentScripts); 217 | 218 | browser.runtime.onInstalled.addListener(async (details) => { 219 | if (details.reason == 'install') { 220 | // Init the default settings 221 | await InitDefaultSettings(); 222 | await InitLocalStorage(); 223 | } 224 | else if (details.reason == 'update') { 225 | // Convert from previous versions 226 | var version = details.previousVersion.split('.'); 227 | 228 | if (parseInt(version[0]) == 3 && parseInt(version[1]) < 16 && isFirefox) { 229 | await ConvertLocalToSync(); 230 | } 231 | 232 | // Init default settings 233 | await InitDefaultSettings(); 234 | await InitLocalStorage(); 235 | } 236 | else if (details.reason == 'browser_update' || details.reason == 'chrome_update') { 237 | // Do nothing 238 | } 239 | 240 | // Make sure to register content scripts 241 | registerContentScripts(); 242 | }); 243 | 244 | async function CheckForPermission(url) { 245 | // Wrap chrome API in a Promise 246 | const perms = await new Promise(resolve => { 247 | chrome.permissions.getAll(resolve); 248 | }); 249 | 250 | // Loop through granted origins and check against the given URL 251 | for (const pattern of perms.origins) { 252 | try { 253 | const urlPattern = new URLPattern({ 254 | protocol: pattern.split("://")[0], 255 | hostname: pattern.split("://")[1].split("/")[0], 256 | pathname: pattern.split("/").slice(3).join("/") || "*" 257 | }); 258 | 259 | if (urlPattern.test(url)) { 260 | return true; 261 | } 262 | } catch (e) { 263 | console.warn("Invalid pattern in permissions:", pattern, e); 264 | } 265 | } 266 | 267 | return false; 268 | } 269 | -------------------------------------------------------------------------------- /src/common/google2letterboxd.js: -------------------------------------------------------------------------------- 1 | 2 | google2letterboxd(); 3 | 4 | async function google2letterboxd(){ 5 | // Get storage 6 | var options = await browser.storage.sync.get().then(function (storedSettings) { 7 | storedSettings = storedSettings['options']; 8 | if (storedSettings["convert-ratings"] === true){ 9 | storedSettings["convert-ratings"] = "5"; 10 | } 11 | return storedSettings; 12 | }); 13 | 14 | // Verify the setting is enabled 15 | if (options['google'] == null || options['google'] === false){ 16 | // This shouldn't happen 17 | return; 18 | } 19 | 20 | // Find element with IMDb 21 | let imdbElements = document.querySelectorAll('a span[aria-hidden="true"]') 22 | imdbElements = Array.from(imdbElements).filter(elm => elm.textContent.trim() === 'IMDb') 23 | 24 | imdbElements.forEach(imdbElement => { 25 | // Find the closest tag (which is the parent link element) 26 | const imdbLink = imdbElement.closest('a') 27 | 28 | let letterboxdElement = imdbLink.parentElement.querySelectorAll('a span[aria-hidden="true"]') 29 | letterboxdElement = Array.from(letterboxdElement).find(elm => elm.textContent.trim() === 'Letterboxd') 30 | if (!letterboxdElement) { 31 | 32 | // Extract the IMDb ID from the href attribute 33 | const imdbUrl = imdbLink.getAttribute('href') 34 | const imdbId = imdbUrl.split('title/').pop().replace('/','') 35 | const letterboxdUrl = `https://letterboxd.com/imdb/${imdbId}` 36 | 37 | // Remove Ratings heading 38 | let headingElement = document.querySelectorAll('span[role="heading"]') 39 | headingElement = Array.from(headingElement).find(elm => elm.textContent.trim() === 'Ratings') 40 | if (headingElement) 41 | headingElement.parentElement.style.display = "none" 42 | 43 | // Remove links if there are too many 44 | if (imdbLink.parentElement.childElementCount >= 5) { 45 | for (let i = imdbLink.parentElement.childElementCount - 1; i >= 3; i--) 46 | imdbLink.parentElement.children[i].remove() 47 | } 48 | 49 | // Add divider 50 | let dividerElement = imdbLink.parentElement.querySelector(':scope > div') 51 | if (dividerElement) { 52 | dividerElement = dividerElement.cloneNode(true) 53 | imdbLink.parentElement.appendChild(dividerElement) 54 | } 55 | 56 | // Construct the letterboxdLink 57 | const letterboxdLink = imdbLink.cloneNode(true) 58 | letterboxdLink.href = letterboxdUrl 59 | 60 | imdbLink.parentElement.appendChild(letterboxdLink) 61 | imdbLink.parentElement.style = "overflow-x:auto; overflow-y:hidden; justify-content:space-between;" 62 | 63 | const spans = letterboxdLink.querySelectorAll('span') 64 | const span1 = Array.from(spans).find(span => span.textContent.includes('/10')) 65 | if (options['convert-ratings'] != null && options['convert-ratings'] === "10"){ 66 | span1.textContent = '/10' 67 | }else{ 68 | span1.textContent = '/5' 69 | } 70 | const span2 = Array.from(spans).find(span => span.textContent.trim() === 'IMDb') 71 | span2.textContent = 'Letterboxd' 72 | 73 | const img = letterboxdLink.querySelector('img') 74 | if (img) 75 | img.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABxklEQVQ4jaWTvWtTYRTGf+fNtUGJcLV+cFsIiEiDt5SipVKKIB0SBAUpROgiDnGQDkLwD8gfIN1sFwcdxI9AF0ExQ8giSF20xEgVpSSkGbRwlQym5N7jYI25sYUGn+k9h/M8POd5OUIPUnPzrvrtTKAkgfh2u2qEgkSsey8f3X3fPS9/Hul0bsCTxgJwU1VNrzCAiATAkq1ONp/PbXUE0uncgEfjhaIzOxH/EUKKNs7FfD63ZQA8aSzslQyg6My2WyQ1N+8Gfnt1N9u7uhAJTMQas9RvZ1TVTB1c50ysTq1l89xL8OOQsDHZAmBoJcoBbx9BYgrso1D/hFkvG/XbGStQklePvOXG8ZWO+ik3yqXUIH5UAahN/+TKx1ts7h//PTB6Hl4/g3elpAHis4PlkD1n9jYjseFOPRIb5vr0UGgmGLsAEO9r751ggOry5mio2Vi+w1qz3qnXmnXuv9oIE1dLAFXLCIWn38ZP11r23xA/nOBcJRzid+8hJvGlO0SMSOG/vzHyufzm60l34hgw2afAYuHJ0gMDYKuTFaS4ZzJStNXJAkQAKpWSf9a9/LglzcMiMkHXkfXaFpFFG+da6Ji60e85/wJ5/LY0w2PuAAAAAABJRU5ErkJggg==" 76 | 77 | // Fetch the Letterboxd page and extract the rating 78 | fetch(letterboxdUrl) 79 | .then(response => response.text()) 80 | .then(text => { 81 | // Use regex to find the ratingValue 82 | const ratingMatch = text.match(/"ratingValue"\s*:\s*(\d+(\.\d+)?)/) 83 | if (ratingMatch) { 84 | const rating = parseFloat(ratingMatch[1]) 85 | if (options['convert-ratings'] != null && options['convert-ratings'] === "10"){ 86 | span1.textContent = `${(rating*2).toFixed(1)}/10` 87 | }else{ 88 | span1.textContent = `${rating.toFixed(1)}/5` 89 | } 90 | } else if (/IMDB ID not found/i.test(text)) { 91 | letterboxdLink.remove() 92 | if (dividerElement) 93 | dividerElement.remove() 94 | if (headingElement) 95 | headingElement.parentElement.style.display = "block" 96 | } else { 97 | console.error('Letterboxd rating not found.') 98 | } 99 | }) 100 | .catch(error => { 101 | console.error('Error fetching Letterboxd page:', error) 102 | }) 103 | 104 | }else if (options['convert-ratings'] != null && options['convert-ratings'] === "10"){ 105 | const spans = letterboxdElement.parentElement.querySelectorAll('span') 106 | const span1 = Array.from(spans).find(span => span.textContent.includes('/5')) 107 | 108 | var rating = parseFloat(span1.textContent.split('/')[0]); 109 | span1.textContent = `${(rating*2).toFixed(1)}/10` 110 | } 111 | }) 112 | } -------------------------------------------------------------------------------- /src/common/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/src/common/icon128.png -------------------------------------------------------------------------------- /src/common/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/src/common/icon16.png -------------------------------------------------------------------------------- /src/common/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/src/common/icon32.png -------------------------------------------------------------------------------- /src/common/icon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/src/common/icon512.png -------------------------------------------------------------------------------- /src/common/images/bfi-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Branding / BFI Logo / Black 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/common/images/filmarks-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/common/images/kinopoisk-logo-eng.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/common/images/kinopoisk-logo-rus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/common/images/mal-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/src/common/images/mal-logo.png -------------------------------------------------------------------------------- /src/common/images/sens-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/src/common/images/sens-logo.png -------------------------------------------------------------------------------- /src/common/images/simkl-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/src/common/images/simkl-logo.png -------------------------------------------------------------------------------- /src/common/images/tomato-audience-hot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Icons/Tomatometer & AS/popcorn_red 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/common/images/tomato-audience-no-score.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Icons/Tomatometer & AS/No Aud Score 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/common/images/tomato-audience-stale.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Icons/Tomatometer & AS/popcorn_green 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/common/images/tomato-audience-verified-hot.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/common/images/tomato-critic-certified-fresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Icons/Tomatometer & AS/certified_fresh 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /src/common/images/tomato-critic-fresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Icons/Tomatometer & AS/fresh 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/common/images/tomato-critic-no-score.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Icons/Tomatometer & AS/No Tomatometer 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/common/images/tomato-critic-rotten.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Icons/Tomatometer & AS/rotten 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/common/options.css: -------------------------------------------------------------------------------- 1 | body { 2 | --body-text-color: #000; 3 | --body-back-color: #ffffff; 4 | --body-text-alt-color: #7d7d7d; 5 | 6 | color: var(--body-text-color); 7 | background-color: var(--body-back-color); 8 | 9 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 10 | 11 | min-width: 450px; 12 | } 13 | 14 | h3, 15 | label, 16 | p, 17 | a, 18 | input#export, 19 | input#import, 20 | input#reset, 21 | input#importbutton, 22 | input#importpicker, 23 | input#requestall { 24 | margin-left: 15px; 25 | margin-right: 15px; 26 | } 27 | 28 | input#export, 29 | input#import { 30 | margin-top: 5px; 31 | } 32 | 33 | input#reset { 34 | margin-top: 25px; 35 | } 36 | 37 | p { 38 | margin-top: 0px; 39 | font-style: italic; 40 | font-size: 11px; 41 | width: 95%; 42 | max-width: 450px; 43 | color: var(--body-text-alt-color); 44 | } 45 | 46 | a { 47 | color: rgb(51, 167, 255); 48 | width: 95%; 49 | text-decoration: none; 50 | } 51 | 52 | a:hover { 53 | opacity: 50%; 54 | text-decoration: underline; 55 | } 56 | 57 | label { 58 | display: inline-block; 59 | width: 175px; 60 | margin-bottom: 10px; 61 | } 62 | 63 | .div-request-permission, 64 | .div-request-contentscript { 65 | display: none; 66 | } 67 | 68 | .div-request-permission label, 69 | .div-request-contentscript label { 70 | font-size: 15px; 71 | } 72 | 73 | .disabled { 74 | pointer-events: none; 75 | opacity: 50%; 76 | } 77 | 78 | .hyperlink { 79 | color: rgb(51, 167, 255); 80 | } -------------------------------------------------------------------------------- /src/common/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |

Missing Permissions

16 |
17 |

You are missing some permissions needed for certain enabled settings.

18 | 19 |
20 |
21 |
22 |
23 |

IMDb

24 |
25 | 26 | 27 |
28 |
29 |
30 |

MyAnimeList

31 |
32 | 33 | 34 |
35 |
36 |
37 |

AniList

38 |
39 | 40 | 41 |
42 |
43 |
44 |

Rotten Tomatoes

45 |
46 | 47 | 48 |
49 |
50 |
51 | 52 | 53 |
54 |
55 | 56 | 57 |
58 |
59 | 60 | 65 |
66 |
67 | 68 | 72 |
73 |
74 | 75 | 79 |
80 |
81 |
82 |
83 |

Metacritic

84 |
85 | 86 | 87 |
88 |
89 |
90 | 91 | 92 |
93 |
94 | 95 | 96 |
97 |
98 | 99 | 100 |
101 |
102 | 103 | 108 |
109 |
110 |
111 |
112 |

SensCritique

113 |
114 | 115 | 116 |
117 |
118 | 119 | 120 |
121 |
122 |
123 | 124 | 125 |
126 |
127 |
128 |
129 |

MUBI

130 |
131 | 132 | 133 |
134 |
135 | 136 | 137 |
138 |
139 |
140 |

FilmAffinity

141 |
142 | 143 | 144 |
145 |
146 | 147 | 148 |
149 |
150 |
151 |

Simkl

152 |
153 | 154 | 155 |
156 |
157 | 158 | 159 |
160 |
161 |
162 |

AlloCiné

163 |
164 | 165 | 166 |
167 |
168 | 169 | 170 |
171 |
172 |
173 | 174 | 175 |
176 |
177 | 178 | 179 |
180 |
181 | 182 | 186 |
187 |
188 |
189 | 190 | 194 |
195 |
196 |
197 |
198 |
199 |

Kinopoisk

200 |
201 | 202 | 203 |
204 |
205 | 206 | 207 |
208 |
209 | 210 | 211 | Get your API key from the unofficial API 212 |

Letterboxd Extras uses an unoffical API to get the ratings from Kinopoisk. If you encounter issues with rate limiting, you can enter your own key.

213 |
214 |
215 |
216 |

Filmarks

217 |
218 | 219 | 220 |
221 |
222 | 223 | 224 |
225 |
226 |
227 |

CinemaScore

228 |
229 | 230 | 231 |
232 |
233 | 234 |
235 | 236 |
237 |

Search

238 |
239 | 240 | 241 |

Defaults the Letterboxd search to only show results for films, rather than showing all results.

242 |
243 |
244 | 245 |
246 | 247 |
248 |

Rankings

249 |
250 |
251 | 252 | 253 |
254 |
255 | 256 | 257 |
258 |
259 |
260 |
261 | 262 | 263 |
264 |
265 | 266 | 267 |
268 |
269 |
270 | 271 |
272 | 273 |
274 |

Content Ratings

275 |
276 | 277 | 288 |
289 |
290 | 291 | 292 |
293 |
294 | 295 |
296 | 297 |
298 |

"Does the Dog Die?" Link

299 |
300 | 301 | 302 |
303 |
304 | 305 | 306 |
307 |
308 | 309 | 310 | Get your API key from your DDD profile 311 |

An API key is supposed to be mandatory for the DDD API, however, the API call seems to work without. If this does not work, you can try to use your own API key.

312 |
313 |
314 | 315 |
316 | 317 |
318 |

Miscellaneous

319 |
320 | 321 | 326 |

Converts ratings to different rating scales. Affects all ratings except for Cinemascore and Rotten Tomatoes percentage.

327 |
328 |
329 | 330 | 331 |
332 |
333 | 334 | 335 |
336 |
337 | 338 | 339 |
340 |
341 | 342 | 347 |
348 |
349 | 350 | 351 |
352 |
353 | 354 | 355 |
356 |
357 | 358 |
359 | 360 |
361 |

Google Search

362 |
363 | 364 | 365 |
366 |
367 | 368 | 369 |
370 |
371 | 372 | 373 |
374 |
375 | 376 |
377 | 378 |
379 |

Import/Export Settings

380 |
381 | 382 |
383 |
384 | 385 | 386 |
387 | 392 |
393 | 394 |
395 |
396 | 397 |
398 | 399 |
400 |

Debug

401 |
402 | 403 | 404 |
405 |
406 | 407 |
408 | 409 |
410 |
411 | Report an issue or suggest a new feature on GitHub 412 |
413 |
414 | 415 |
416 | 417 | 418 | 419 | 420 | -------------------------------------------------------------------------------- /src/common/options.js: -------------------------------------------------------------------------------- 1 | const isFirefox = typeof browser !== "undefined" && typeof browser.runtime !== "undefined"; 2 | const isChrome = typeof chrome !== "undefined" && typeof browser === "undefined"; 3 | document.body.classList.add(isFirefox ? "firefox" : "chrome"); 4 | 5 | if (isChrome) 6 | var browser = chrome; 7 | 8 | let isAndroid = (navigator.userAgent.includes('Android')); 9 | let isPopup = window.location.search.includes('type=action'); 10 | 11 | if ((isAndroid || isPopup) && isFirefox) 12 | AndroidImportReplacer(); 13 | 14 | var options = {}; 15 | 16 | var missingHostPermissions = []; 17 | var missingContentScripts = []; 18 | 19 | // Load from storage 20 | async function load() { 21 | // Assign the object 22 | var data = await browser.storage.sync.get('options'); 23 | 24 | if (data != null && data.options != null) { 25 | Object.assign(options, data.options); 26 | // Set the settings 27 | await set(); 28 | } 29 | } 30 | 31 | // Save 32 | function save() { 33 | browser.storage.sync.set({ options }); 34 | } 35 | 36 | async function set() { 37 | var elements = document.querySelectorAll('.setting'); 38 | for (let i = 0; i < elements.length; i++) { 39 | let element = elements[i]; 40 | var key = element.id; 41 | if (options.hasOwnProperty(key)) { 42 | switch (element.type) { 43 | case ('checkbox'): 44 | element.checked = options[key]; 45 | break; 46 | case ('text'): 47 | element.value = options[key]; 48 | break; 49 | default: 50 | element.value = options[key]; 51 | break; 52 | } 53 | 54 | if (element.getAttribute("permission") != null) { 55 | ValidatePermission(element); 56 | ValidateContentScript(element); 57 | } 58 | } 59 | checkSubIDToDisable(element); 60 | } 61 | } 62 | 63 | function checkSubIDToDisable(element) { 64 | if (element.getAttribute("subid") != null) { 65 | var target = document.querySelector("#" + element.getAttribute("subid")); 66 | var targetValue = element.getAttribute("subidvalue"); 67 | 68 | if (targetValue != null) { 69 | if (element.value == targetValue) { 70 | target.className = target.className.replace("disabled", ""); 71 | } else if (!target.className.includes("disabled")) { 72 | target.className += " disabled"; 73 | } 74 | } else { 75 | if (element.checked) { 76 | target.className = target.className.replace("disabled", ""); 77 | } else if (!target.className.includes("disabled")) { 78 | target.className += " disabled"; 79 | } 80 | } 81 | } 82 | } 83 | 84 | 85 | // On change, save 86 | document.addEventListener('change', event => { 87 | if (event.target.id == "importpicker") { 88 | validateImportButton(); 89 | } else { 90 | let element = event.target; 91 | 92 | switch (element.type) { 93 | case ('checkbox'): 94 | options[element.id] = element.checked; 95 | break; 96 | case ('text'): 97 | options[element.id] = element.value; 98 | break; 99 | default: 100 | options[element.id] = element.value; 101 | break; 102 | } 103 | checkSubIDToDisable(element); 104 | 105 | save(); 106 | 107 | // Check for permissions 108 | var origins = element.getAttribute("permission") ? [element.getAttribute("permission")] : []; 109 | var permissions = element.getAttribute("permissionBrowser") ? [element.getAttribute("permissionBrowser")] : []; 110 | 111 | if (origins.length > 0 || permissions.length > 0){ 112 | let permissionsToRequest = { origins: origins, permissions: permissions }; 113 | 114 | if (element.checked == true) { 115 | // Request the permission 116 | browser.permissions.request(permissionsToRequest, (granted) => { 117 | if (granted) { 118 | ValidatePermission(element); 119 | if (element.getAttribute('contentScript') != null) { 120 | registerContentScript(element); 121 | } 122 | } else { 123 | element.checked = false; 124 | options[element.id] = element.checked; 125 | save(); 126 | } 127 | checkSubIDToDisable(element); 128 | }); 129 | } else { 130 | // Remove the permission 131 | browser.permissions.remove(permissionsToRequest, (removed) => { 132 | if (removed) { 133 | ValidatePermission(element); 134 | if (element.getAttribute('contentScript') != null) { 135 | registerContentScript(element); 136 | } 137 | } else { 138 | element.checked = true; 139 | options[element.id] = element.checked; 140 | save(); 141 | } 142 | checkSubIDToDisable(element); 143 | }); 144 | } 145 | } 146 | } 147 | }); 148 | 149 | async function ValidateAllPermissions(){ 150 | var elements = document.querySelectorAll('.setting'); 151 | for (let i = 0; i < elements.length; i++) { 152 | let element = elements[i]; 153 | var key = element.id; 154 | if (options.hasOwnProperty(key)) { 155 | switch (element.type) { 156 | case ('checkbox'): 157 | element.checked = options[key]; 158 | break; 159 | case ('text'): 160 | element.value = options[key]; 161 | break; 162 | default: 163 | element.value = options[key]; 164 | break; 165 | } 166 | 167 | if (element.getAttribute("permission") != null) { 168 | ValidatePermission(element); 169 | ValidateContentScript(element); 170 | } 171 | } 172 | checkSubIDToDisable(element); 173 | } 174 | } 175 | 176 | // Validate Permission - check to see if the permission is granted, and determine visibility of the warning 177 | async function ValidatePermission(element){ 178 | let permission = element.getAttribute("permission"); 179 | let permissionsToRequest = { origins: [permission] }; 180 | var response = await browser.permissions.contains(permissionsToRequest); 181 | 182 | var div = element.parentNode.parentNode.querySelector(".div-request-permission"); 183 | if (div != null) { 184 | if (response == false && element.checked == true) { 185 | // Permission does NOT exist and setting is enabled 186 | div.setAttribute("style", "display:block;"); 187 | 188 | if (!missingHostPermissions.includes(permission)){ // add to array 189 | missingHostPermissions.push(permission); 190 | } 191 | } else { 192 | div.setAttribute("style", "display:none;"); 193 | 194 | if (missingHostPermissions.includes(permission)){ // remove from array 195 | missingHostPermissions.splice(missingHostPermissions.indexOf(permission), 1); 196 | } 197 | } 198 | } 199 | 200 | ValidateRequestAllVisiblity(); 201 | } 202 | 203 | // Validate Content Script - check to see if the content script has been registered, and determine visibility of the warning 204 | async function ValidateContentScript(element){ 205 | let js = element.getAttribute("contentScript"); 206 | let id = element.getAttribute("contentScriptID"); 207 | if (js != null && id != null){ 208 | let scripts = await browser.scripting.getRegisteredContentScripts(); 209 | if (scripts != null){ 210 | scripts = scripts.map((script) => script.id); 211 | response = (scripts.includes(id)); 212 | }else{ 213 | response = false; 214 | } 215 | 216 | div = element.parentNode.parentNode.querySelector(".div-request-contentscript"); 217 | if (div != null) { 218 | if (response == false && element.checked == true) { 219 | // Permission does NOT exist and setting is enabled 220 | div.setAttribute("style", "display:block;"); 221 | 222 | if (!missingContentScripts.some(obj => obj.id === id)){ // add to array 223 | missingContentScripts.push({ id: id, js: [js], matches: [element.getAttribute("permission")] }); 224 | } 225 | } else { 226 | div.setAttribute("style", "display:none;"); 227 | 228 | const script = missingContentScripts.find(obj => obj.id == id); 229 | if (script != null){ // remove from array 230 | missingContentScripts.splice(missingContentScripts.indexOf(script), 1); 231 | } 232 | } 233 | } 234 | } 235 | } 236 | 237 | 238 | // Request permission if missing 239 | document.addEventListener('click', event => { 240 | if (event.target.getAttribute("class") != null && event.target.getAttribute("class") == "request-permission" && event.target.getAttribute("permissionTarget") != null) { 241 | requestPermission(event); 242 | } 243 | if (event.target.getAttribute("class") != null && event.target.getAttribute("class") == "request-contentscript" && event.target.getAttribute("permissionTarget") != null) { 244 | var target = document.querySelector("#" + event.target.getAttribute("permissionTarget")); 245 | registerContentScript(target); 246 | } 247 | 248 | switch (event.target.id) { 249 | case "export": 250 | exportSettings(); 251 | break; 252 | case "import": 253 | importSettings(); 254 | break; 255 | case "reset": 256 | resetSettings(); 257 | break; 258 | case "importbutton": 259 | OpenImportTab(); 260 | break; 261 | case "requestall": 262 | RequestAllMissingPermissions(); 263 | break; 264 | } 265 | }); 266 | 267 | async function requestPermission(event) { 268 | // Get the element that has the permission 269 | var permissionTarget = document.querySelector("#" + event.target.getAttribute("permissionTarget")); 270 | // Make sure it has the permission 271 | if (permissionTarget.getAttribute("permission") != null) { 272 | // Get the permission from the element that has it 273 | var permission = permissionTarget.getAttribute("permission"); 274 | let permissionsToRequest = { origins: [permission] }; 275 | 276 | // Request the permission 277 | const response = await browser.permissions.request(permissionsToRequest); 278 | if (response == true) { 279 | event.target.parentNode.setAttribute("style", "display:none;"); 280 | 281 | // Remove from array 282 | if (missingHostPermissions.includes(permission)){ 283 | missingHostPermissions.splice(missingHostPermissions.indexOf(permission), 1); 284 | } 285 | }else{ 286 | // Add to array 287 | if (!missingHostPermissions.includes(permission)){ 288 | missingHostPermissions.push(permission); 289 | } 290 | } 291 | 292 | ValidateRequestAllVisiblity(); 293 | } 294 | } 295 | 296 | async function registerContentScript(target) { 297 | var id = target.getAttribute('contentScriptID'); 298 | var js = target.getAttribute('contentScript'); 299 | var match = target.getAttribute('permission'); 300 | 301 | if (target.checked) { 302 | // Register 303 | const script = { 304 | id: id, 305 | js: [js], 306 | matches: [match], 307 | }; 308 | 309 | try { 310 | await browser.scripting.registerContentScripts([script]); 311 | } catch (err) { 312 | console.error(`failed to register content scripts: ${err}`); 313 | return false; 314 | } 315 | 316 | var request = target.parentNode.parentNode.querySelector('.div-request-contentscript'); 317 | if (request != null) { 318 | request.setAttribute("style", "display:none;"); 319 | } 320 | } else { 321 | // Unregister 322 | try { 323 | await browser.scripting.unregisterContentScripts({ 324 | ids: [id], 325 | }); 326 | } catch (err) { 327 | console.error(`failed to unregister content scripts: ${err}`); 328 | return false; 329 | } 330 | } 331 | 332 | return true; 333 | } 334 | 335 | // On load, load 336 | document.addEventListener('DOMContentLoaded', event => { 337 | load(); 338 | validateImportButton(); 339 | }); 340 | 341 | document.addEventListener('focus', event => { 342 | //ValidateAllPermissions(); 343 | }); 344 | 345 | function validateImportButton() { 346 | const importPicker = document.querySelector("#importpicker"); 347 | const importButton = document.querySelector("#import"); 348 | 349 | importButton.disabled = (importPicker.value == ""); 350 | } 351 | 352 | async function exportSettings() { 353 | // Create JSON 354 | var settings = await browser.storage.sync.get('options').then(function (storedSettings) { 355 | return storedSettings; 356 | }); 357 | 358 | const userdata = { 359 | timeStamp: Date.now(), 360 | version: browser.runtime.getManifest().version, 361 | settings: settings.options 362 | } 363 | 364 | const timeOptions = { 365 | year: 'numeric', 366 | month: 'numeric', 367 | day: 'numeric', 368 | hour: 'numeric', 369 | minute: 'numeric', 370 | second: 'numeric' 371 | }; 372 | 373 | var url = 'data:text/plain;charset=utf-8,' + encodeURIComponent(JSON.stringify(userdata, null, ' ')); 374 | var date = (new Date).toLocaleDateString('ja-JP', timeOptions); 375 | var filename = 'letterboxd-extras-backup-' + date + '.json'; 376 | 377 | // Download 378 | const a = document.createElement('a'); 379 | a.href = url; 380 | a.setAttribute('download', filename || ''); 381 | a.setAttribute('type', 'text/plain'); 382 | a.dispatchEvent(new MouseEvent('click')); 383 | } 384 | 385 | async function importSettings() { 386 | const importPicker = document.querySelector("#importpicker"); 387 | 388 | // Make sure file is selected 389 | if (importPicker.files.length == 0) { 390 | window.alert("No file selected.") 391 | return; 392 | } 393 | 394 | // Get file and read the contents 395 | const selectedFile = importPicker.files[0]; 396 | const content = await readFileAsText(selectedFile); 397 | 398 | var json; 399 | var error = ""; 400 | try { 401 | json = JSON.parse(content); 402 | } catch(err) { 403 | error = "File is not valid JSON." 404 | } 405 | 406 | if (json != null){ 407 | // Validate file contents 408 | if (json.timeStamp == null || json.version == null || json.settings == null){ 409 | error = "File is not a valid Letterboxd Extras backup." 410 | } 411 | if (json.version != null && versionCompare(json.version, browser.runtime.getManifest().version, {lexicographical: false, zeroExtend: true}) > 0){ 412 | error = "Backup is from a newer version (" + json.version + ") than the current add-on (" + browser.runtime.getManifest().version + "). Please update before importing settings." 413 | } 414 | } 415 | 416 | if (error != ""){ 417 | window.alert("Invalid file: " + error + "\n\nThe import could not be completed"); 418 | return; 419 | } 420 | 421 | // Read timestamp from file 422 | const date = (new Date(json.timeStamp)).toLocaleDateString(window.navigator.language); 423 | 424 | // Confirmation Popup 425 | if (!window.confirm("Your settings will be overwritten with data backed up on " + date + ".\n\nOverwrite all settings with data from file?")) { 426 | return; 427 | } 428 | 429 | options = json.settings; 430 | set(); 431 | save(); 432 | 433 | window.alert("Your settings have been restored from file") 434 | 435 | if (window.location.href.endsWith('restore.html')){ 436 | browser.tabs.create({ 437 | url: "/options.html", 438 | active: true 439 | }); 440 | 441 | window.close(); 442 | } 443 | } 444 | 445 | async function resetSettings(){ 446 | // Confirmation Popup 447 | if (!window.confirm("Your settings will be reset.\n\nReset all settings to default?")) { 448 | return; 449 | } 450 | 451 | browser.runtime.sendMessage({ name: "RESETSETTINGS" }, (value) => { 452 | load(); 453 | window.alert("Your settings have been reset to default.") 454 | }); 455 | } 456 | 457 | async function readFileAsText(file) { 458 | return new Promise((resolve, reject) => { 459 | const reader = new FileReader(); 460 | 461 | reader.onload = function(e) { 462 | resolve(e.target.result); // Resolve the promise with file content 463 | }; 464 | 465 | reader.onerror = function(e) { 466 | reject(e); // Reject the promise if an error occurs 467 | }; 468 | 469 | reader.readAsText(file); 470 | }); 471 | } 472 | 473 | // https://gist.github.com/TheDistantSea/8021359 474 | function versionCompare(v1, v2, options) { 475 | var lexicographical = options && options.lexicographical, 476 | zeroExtend = options && options.zeroExtend, 477 | v1parts = v1.split('.'), 478 | v2parts = v2.split('.'); 479 | 480 | function isValidPart(x) { 481 | return (lexicographical ? /^\d+[A-Za-z]*$/ : /^\d+$/).test(x); 482 | } 483 | 484 | if (!v1parts.every(isValidPart) || !v2parts.every(isValidPart)) { 485 | return NaN; 486 | } 487 | 488 | if (zeroExtend) { 489 | while (v1parts.length < v2parts.length) v1parts.push("0"); 490 | while (v2parts.length < v1parts.length) v2parts.push("0"); 491 | } 492 | 493 | if (!lexicographical) { 494 | v1parts = v1parts.map(Number); 495 | v2parts = v2parts.map(Number); 496 | } 497 | 498 | for (var i = 0; i < v1parts.length; ++i) { 499 | if (v2parts.length == i) { 500 | return 1; 501 | } 502 | 503 | if (v1parts[i] == v2parts[i]) { 504 | continue; 505 | } 506 | else if (v1parts[i] > v2parts[i]) { 507 | return 1; 508 | } 509 | else { 510 | return -1; 511 | } 512 | } 513 | 514 | if (v1parts.length != v2parts.length) { 515 | return -1; 516 | } 517 | 518 | return 0; 519 | } 520 | 521 | async function OpenImportTab(){ 522 | let permissionsToRequest = { permissions: ['tabs'] }; 523 | const response = await browser.permissions.request(permissionsToRequest); 524 | if (response == true) { 525 | browser.tabs.create({ 526 | url: "/restore.html", 527 | active: true 528 | }); 529 | window.close(); 530 | } 531 | } 532 | 533 | // Make a link to the android replacer page 534 | // For some reason, FF on android has a bug where the filepicker does not work in the options_ui 535 | // So we have a separate page where we want the android users to use instead 536 | function AndroidImportReplacer(){ 537 | if (document.URL.endsWith('restore.html')) 538 | return; 539 | 540 | const importDesktop = document.getElementById("importdivdesktop"); 541 | importDesktop.style.display = 'none'; 542 | 543 | const importAndroid = document.getElementById("importdivandroid"); 544 | importAndroid.style.display = ''; 545 | 546 | // Show the tab permission reminder 547 | browser.permissions.contains({ permissions: ['tabs'] }).then((value) => { 548 | let reminder = document.getElementById('tabpermissionreminder'); 549 | 550 | if (reminder != null){ 551 | if (isAndroid == false && value == false){ 552 | reminder.style.display = ''; 553 | }else{ 554 | reminder.style.display = 'none'; 555 | } 556 | } 557 | }); 558 | } 559 | 560 | function ValidateRequestAllVisiblity(){ 561 | const requestDiv = document.getElementById('requestalldiv'); 562 | if (requestDiv != null){ 563 | if (missingHostPermissions.length > 0 || missingContentScripts.length > 0){ 564 | requestDiv.style.display = ''; 565 | }else{ 566 | requestDiv.style.display = 'none'; 567 | } 568 | } 569 | } 570 | 571 | async function RequestAllMissingPermissions(){ 572 | // Request any missing permissions 573 | if (missingHostPermissions.length > 0){ 574 | let permissionsToRequest = { origins: missingHostPermissions }; 575 | var response = await browser.permissions.request(permissionsToRequest); 576 | if (response == true) { 577 | missingHostPermissions = []; 578 | }else{ 579 | } 580 | } 581 | 582 | // Request any content scripts 583 | if (missingContentScripts.length > 0){ 584 | try { 585 | await browser.scripting.registerContentScripts(missingContentScripts); 586 | missingContentScripts = []; 587 | } catch (err) { 588 | console.error(`failed to register content scripts: ${err}`); 589 | } 590 | } 591 | await set(); 592 | } -------------------------------------------------------------------------------- /src/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "letterboxd-extras", 3 | "version": "1.0.0", 4 | "description": "Displays additional scores on Letterboxd (IMDB, Rotten Tomatoes, Metacritic).", 5 | "scripts": { 6 | "test": "eslint ." 7 | }, 8 | "repository": "https://github.com/duncanlang/Letterboxd-Extras.git", 9 | "author": "Duncan Lang (https://github.com/duncanlang)", 10 | "license": "MIT", 11 | "engines": { 12 | "node": ">=8.0.0" 13 | }, 14 | "devDependencies": { 15 | "eslint": "^6.5.1", 16 | "eslint-config-aqua": "^7.0.1" 17 | }, 18 | "eslintConfig": { 19 | "extends": [ 20 | "aqua" 21 | ], 22 | "env": { 23 | "browser": true, 24 | "es6": true, 25 | "webextensions": true 26 | }, 27 | "rules": { 28 | "func-names": 0, 29 | "capitalized-comments": 0, 30 | "max-len": 0, 31 | "id-length": 0, 32 | "no-warning-comments": 0, 33 | "no-throw-literal": 0, 34 | "no-invalid-this": 0, 35 | "no-console": 0, 36 | "padded-blocks": 0, 37 | "quote-props": 0 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/common/polyfill.js: -------------------------------------------------------------------------------- 1 | /* Polyfill for non userscript environments */ 2 | /* eslint-disable camelcase, no-unused-vars */ 3 | 4 | function GM_addStyle(styles) { 5 | const style = document.createElement('style'); 6 | style.textContent = styles; 7 | (document.head || document.body || document.documentElement || document).appendChild(style); 8 | } 9 | 10 | function GM_xmlhttpRequest(options) { 11 | const request = new XMLHttpRequest(); 12 | 13 | request.onload = function() { 14 | options.onload(this); 15 | }; 16 | 17 | request.onerror = function() { 18 | options.onerror(this); 19 | }; 20 | 21 | request.ontimeout = function() { 22 | options.ontimeout(this); 23 | }; 24 | 25 | request.open(options.method, options.url); 26 | 27 | if (options.headers) { 28 | for (const header in options.headers) { 29 | if (options.headers.hasOwnProperty(header)) { 30 | request.setRequestHeader(header, options.headers[header]); 31 | } 32 | } 33 | } 34 | 35 | if (typeof options.timeout !== 'undefined') { 36 | request.timeout = options.timeout; 37 | } 38 | 39 | if (typeof options.data === 'undefined') { 40 | request.send(); 41 | } else { 42 | request.send(options.data); 43 | } 44 | } 45 | 46 | /* Extensions cannot access the document's window directly */ 47 | function inject(func) { 48 | const script = document.createElement('script'); 49 | script.appendChild(document.createTextNode(`(${func})();`)); 50 | const target = document.head || document.body || document.documentElement || document; 51 | target.appendChild(script); 52 | target.removeChild(script); // Removing since once it executes it is no longer needed 53 | } 54 | -------------------------------------------------------------------------------- /src/firefox/colour_scheme.css: -------------------------------------------------------------------------------- 1 | @media (prefers-color-scheme: light) { 2 | body { 3 | --body-text-color: #000; 4 | --body-back-color: #ffffff; 5 | --body-text-alt-color: #7d7d7d; 6 | } 7 | } 8 | 9 | @media (prefers-color-scheme: dark) { 10 | body { 11 | --body-text-color: #f4f4f4; 12 | --body-back-color: #23222b; 13 | --body-text-alt-color: #a3a2a7; 14 | } 15 | } -------------------------------------------------------------------------------- /src/firefox/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Letterboxd Extras", 4 | "description": "Displays additional scores on Letterboxd (IMDB, Rotten Tomatoes, Metacritic).", 5 | "version": "3.17.0", 6 | "minimum_chrome_version": "88", 7 | 8 | "browser_specific_settings": { 9 | "gecko_android": { 10 | "strict_min_version": "120.0" 11 | }, 12 | "gecko": { 13 | "id": "{9abb0ce4-6348-4119-b97a-3f8116351aca}", 14 | "strict_min_version": "109.0" 15 | } 16 | }, 17 | 18 | "browser_action": { 19 | "default_title": "Letterboxd Extras Settings", 20 | "default_popup": "options.html?type=action", 21 | "default_icon": { 22 | "16": "icon16.png", 23 | "32": "icon32.png", 24 | "128": "icon128.png" 25 | } 26 | }, 27 | 28 | "icons": { 29 | "128": "icon128.png" 30 | }, 31 | 32 | "options_ui": { 33 | "page": "options.html", 34 | "browser_style": true 35 | }, 36 | 37 | "permissions": [ 38 | "https://letterboxd.com/*", 39 | "https://*.imdb.com/*", 40 | "https://www.rottentomatoes.com/*", 41 | "https://www.boxofficemojo.com/*", 42 | "https://webapp.cinemascore.com/*", 43 | "https://query.wikidata.org/*", 44 | "https://www.metacritic.com/*", 45 | "https://api.jikan.moe/*", 46 | "https://graphql.anilist.co/*", 47 | "storage", 48 | "scripting" 49 | ], 50 | 51 | "optional_permissions": [ 52 | "https://apollo.senscritique.com/*", 53 | "https://api.mubi.com/*", 54 | "https://*.filmaffinity.com/*", 55 | "https://www.theyshootpictures.com/*", 56 | "https://www.bfi.org.uk/*", 57 | "https://www.allocine.fr/*", 58 | "https://api.simkl.com/*", 59 | "https://www.google.com/search*", 60 | "https://www.doesthedogdie.com/*", 61 | "https://markuapi.apn.leapcell.app/*", 62 | "https://kinopoiskapiunofficial.tech/api/*", 63 | "tabs" 64 | ], 65 | 66 | "content_scripts": [ 67 | { 68 | "matches": [ "https://letterboxd.com/*" ], 69 | "js": [ "polyfill.js", "letterboxd-extras.user.js" ], 70 | "run_at": "document_start" 71 | } 72 | ], 73 | 74 | "background": { 75 | "scripts": ["background.js"], 76 | "persistent": false 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/firefox/restore.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 93 | 94 | 95 | 96 |
97 |

Import Settings From File

98 |
99 | 100 | 101 |

This will return back to the settings to allow you to request any missing permissions.

102 |
103 |
104 | 105 |
106 | 107 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /src/pack chrome.lnk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/src/pack chrome.lnk -------------------------------------------------------------------------------- /src/pack firefox.lnk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duncanlang/Letterboxd-Extras/0e0fb6c300231e6eb9b07131166f5b7cb785e59b/src/pack firefox.lnk -------------------------------------------------------------------------------- /src/pack.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal enabledelayedexpansion 3 | 4 | :: Ask for target if not passed 5 | if "%1"=="" ( 6 | echo Usage: build.bat chrome ^| firefox 7 | exit /b 1 8 | ) 9 | 10 | set TARGET=%1 11 | set SOURCE_DIR=%~dp0 12 | set COMMON_DIR=%SOURCE_DIR%common 13 | set TARGET_DIR=%SOURCE_DIR%%TARGET% 14 | set DIST_DIR=%SOURCE_DIR%dist\%TARGET% 15 | 16 | :: Full path to output zip file 17 | set ZIP_FILE=%SOURCE_DIR%Letterboxd-Extras-%TARGET%.zip 18 | 19 | :: Delete old zip if it exists 20 | if exist "%ZIP_FILE%" del "%ZIP_FILE%" 21 | 22 | :: Create zip using 7-Zip 23 | "C:\Program Files\7-Zip\7z.exe" a -tzip "%ZIP_FILE%" "%DIST_DIR%\*" 24 | 25 | :: Check for success 26 | if errorlevel 1 ( 27 | echo Failed to create archive with 7-Zip. 28 | exit /b 1 29 | ) else ( 30 | echo Archive created successfully: %ZIP_FILE% 31 | ) 32 | --------------------------------------------------------------------------------