Greasy Fork

Letterboxd ListSearch Plus

Search and filter Letterboxd lists with advanced options

// ==UserScript==
// @name         Letterboxd ListSearch Plus
// @namespace    https://greasyfork.org/users/1484969
// @license      MIT
// @version      1.0.2
// @description  Search and filter Letterboxd lists with advanced options
// @match        https://letterboxd.com/*/list/*
// @exclude      https://letterboxd.com/*/list/*/page*
// @exclude      https://letterboxd.com/*/list/*/edit*
// @exclude      https://letterboxd.com/*/list/*/stats*
// @exclude      https://letterboxd.com/*/list/*/detail*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const style = document.createElement('style');
    style.textContent = `
.user-search-wrapper {
    margin: 20px 0;
    display: flex;
    justify-content: center;
    align-items: center;
    position: relative;
}

.load-status {
    margin-right: 8px;
    display: flex;
    align-items: center;
    font: 13px Graphik-Regular-Web, sans-serif;
    color: #ccc;
}

.spinner {
    display: inline-block;
    width: 16px;
    height: 16px;
    min-width: 16px;
    min-height: 16px;
    border: 2px solid rgba(255,255,255,0.4);
    border-top-color: #00ac1c;
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
    margin-right: 6px;
    box-sizing: border-box;
    flex-shrink: 0;
}

@keyframes spin {
    from { transform: rotate(0deg); }
    to   { transform: rotate(360deg); }
}

.user-search {
    width: min(600px, 100%);
    padding: 0 48px 0 16px;
    height: 40px;
    font: 14px/1.2 Graphik-Regular-Web, sans-serif;
    color: #222;
    background: #fff;
    border: 1px solid #d3d3d3;
    border-radius: 20px;
    box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
    transition: border-color .2s, box-shadow .2s;
}

.user-search:focus {
    border-color: #3a7ca5;
    box-shadow: 0 0 0 2px rgba(58,124,165,0.3);
    outline: none;
}

.user-search-button {
    position: absolute;
    right: 16px;
    top: 50%;
    width: 30px;
    height: 30px;
    transform: translateY(-50%);
    border: none;
    background: url('https://s.ltrbxd.com/static/img/sprite-Cmcg-tqK.svg') no-repeat;
    background-size: 800px 1020px;
    background-position: -100px -170px;
    cursor: pointer;
    background-color: transparent;
    text-indent: -9999px;
}

.user-advanced-toggle {
    position: absolute;
    right: 60px;
    top: 50%;
    transform: translateY(-50%);
    border-radius: 20px;
    padding: 0 8px;
    font: 14px/1.2 Graphik-Regular-Web, sans-serif;
    cursor: pointer;
    background-color: #f0f0f0;
    color: #555;
    border: 1px solid #d3d3d3;
}

.user-search-wrapper.advanced-mode .user-advanced-toggle {
    background-color: #00ac1c;
    color: #fff;
    border-color: #00ac1c;
}
`;
    document.head.appendChild(style);

    const wrapper = document.createElement('div');
    wrapper.className = 'user-search-wrapper';

    const status = document.createElement('div');
    status.className = 'load-status';
    const spinner = document.createElement('div');
    spinner.className = 'spinner';
    const statusText = document.createElement('span');
    statusText.textContent = 'Loading… (0 movies)';
    status.append(spinner, statusText);

    const input = document.createElement('input');
    input.className = 'user-search';
    input.placeholder = 'Search list…';

    const advBtn = document.createElement('button');
    advBtn.className = 'user-advanced-toggle';
    advBtn.type = 'button';
    advBtn.textContent = 'Advanced';
    advBtn.setAttribute('aria-label', 'Toggle advanced search');

    const btn = document.createElement('button');
    btn.className = 'user-search-button';
    btn.type = 'button';
    btn.setAttribute('aria-label', 'Search');

    wrapper.append(status, input, advBtn, btn);

    const listEl = document.querySelector('.js-list-entries');
    if (listEl && listEl.parentNode) {
        listEl.parentNode.insertBefore(wrapper, listEl);
    } else {
        const fallback = document.querySelector('.section > .tags') || document.querySelector('.sidebar');
        fallback && fallback.insertBefore(wrapper, fallback.firstChild);
    }

    input.disabled = btn.disabled = advBtn.disabled = true;

    let advancedMode = false;
    advBtn.addEventListener('click', () => {
        advancedMode = !advancedMode;
        wrapper.classList.toggle('advanced-mode', advancedMode);
        // console.log('Advanced search mode:', advancedMode);
    });

    const parser = new DOMParser();
    async function getDom(i) {
        const res = await fetch(`${window.location.href}page/${i}/`);
        const doc = parser.parseFromString(await res.text(), 'text/html');
        const movies = doc.querySelectorAll('.js-list-entries > li');
        // console.log(i, movies);
        return movies.length ? Array.from(movies) : undefined;
    }

    const container = document.querySelector('.js-list-entries');
    const items = Array.from(container.querySelectorAll('li'));
    const nodelists = [];

    let loadedCount = items.length;
    statusText.textContent = `Loading… (${loadedCount} movies)`;

    (async () => {
        for (let i = 2; i <= 100; i++) {
            const page = await getDom(i);
            if (!page) break;
            nodelists.push(page);
            loadedCount += page.length;
            statusText.textContent = `Loading… (${loadedCount} movies)`;
        }
        spinner.remove();
        statusText.textContent = `Loaded ${loadedCount} movies`;
        input.disabled = btn.disabled = advBtn.disabled = false;
    })();

    function parseQuery(input) {
        const tokens = input.match(/-?"[^"]*"|[()|]|-?[^()\s|]+/g) || [];
        let pos = 0;
        function peek() { return tokens[pos]; }
        function consume(tok) {
            if (!tok || peek() === tok) pos++;
            else throw new Error(`Expected ${tok} but got ${peek()}`);
        }
        function parseExpression() { return parseOr(); }
        function parseOr() {
            let node = parseAnd();
            while (peek() === '|') {
                consume('|');
                const right = parseAnd();
                node = { type: 'OR', children: [node, right] };
            }
            return node;
        }
        function parseAnd() {
            let node = parseNot();
            while (peek() && peek() !== ')' && peek() !== '|') {
                const right = parseNot();
                node = { type: 'AND', children: [node, right] };
            }
            return node;
        }
        function parseNot() {
            const tok = peek();
            if (tok && tok.startsWith('-') && tok.length > 1) {
                consume();
                const sub = tok.slice(1);
                if (sub.startsWith('"') && sub.endsWith('"')) {
                    const phrase = sub.slice(1, -1);
                    return { type: 'NOT', child: { type: 'TERM', value: phrase.toLowerCase() } };
                }
                return { type: 'NOT', child: { type: 'TERM', value: sub.toLowerCase() } };
            }
            return parseTerm();
        }
        function parseTerm() {
            const tok = peek();
            if (tok === '(') {
                consume('(');
                const node = parseExpression();
                if (peek() === ')') consume(')');
                return node;
            }
            if (!tok) throw new Error('Unexpected end of input');
            consume();
            if (tok.startsWith('"') && tok.endsWith('"')) {
                const phrase = tok.slice(1, -1);
                return { type: 'TERM', value: phrase.toLowerCase() };
            }
            return { type: 'TERM', value: tok.toLowerCase() };
        }
        const ast = parseExpression();
        if (pos < tokens.length) throw new Error('Unexpected token: ' + peek());
        return ast;
    }
    function evaluateAST(node, text) {
        switch (node.type) {
            case 'TERM': return text.includes(node.value);
            case 'NOT':  return !evaluateAST(node.child, text);
            case 'AND':  return node.children.every(c => evaluateAST(c, text));
            case 'OR':   return node.children.some(c => evaluateAST(c, text));
        }
    }

    function normalizeText(str) {
        return str
            .normalize('NFD')
            .replace(/[\u0300-\u036f]/g, '')
            .toLowerCase();
    }

    function getSearchText(li) {
        const ds  = li.childNodes[1]?.dataset.filmName || '';
        const alt = li.querySelector('div > img')?.alt || '';
        return normalizeText(ds + '|' + alt);
    }

    function triggerLazyLoad() {
        requestAnimationFrame(() => {
            window.dispatchEvent(new Event('scroll'));
        });
    }

    function doSearch() {
        const raw = input.value.trim();
        const normTerm = normalizeText(raw);
        container.innerHTML = '';

        if (!raw) {
            items.forEach(i => container.appendChild(i));
            statusText.textContent = `Showing ${loadedCount} of ${loadedCount} movies`;
            triggerLazyLoad();
            return;
        }

        if (advancedMode) {
            let ast;
            try {
                ast = parseQuery(normTerm);
            } catch {
                items.forEach(li => {
                    const name = getSearchText(li);
                    if (name.includes(normTerm)) container.appendChild(li);
                });
                nodelists.forEach(list => list.forEach(li => {
                    const name = getSearchText(li);
                    if (name.includes(normTerm)) container.appendChild(li);
                }));
                statusText.textContent = `Showing ${container.children.length} of ${loadedCount} movies`;
                triggerLazyLoad();
                return;
            }
            items.forEach(li => {
                const name = getSearchText(li);
                if (evaluateAST(ast, name)) container.appendChild(li);
            });
            nodelists.forEach(list => list.forEach(li => {
                const name = getSearchText(li);
                if (evaluateAST(ast, name)) container.appendChild(li);
            }));
            statusText.textContent = `Showing ${container.children.length} of ${loadedCount} movies`;
            triggerLazyLoad();
            return;
        }

        items.forEach(li => {
            const name = getSearchText(li);
            if (name.includes(normTerm)) container.appendChild(li);
        });
        nodelists.forEach(list => list.forEach(li => {
            const name = getSearchText(li);
            if (name.includes(normTerm)) container.appendChild(li);
        }));
        statusText.textContent = `Showing ${container.children.length} of ${loadedCount} movies`;
        triggerLazyLoad();
    }

    input.addEventListener('keypress', e => { if (e.key === 'Enter') doSearch(); });
    btn.addEventListener('click', doSearch);

})();