Natively Recap

Generate a list of everything you watched or read on Natively this year

// ==UserScript==
// @name          Natively Recap
// @namespace     https://learnnatively.com
// @description   Generate a list of everything you watched or read on Natively this year
// @author        araigoshi
// @version       1.0.9
// @match         https://learnnatively.com/*
// @license       MIT
// @grant         none
// ==/UserScript==

(async function () {
    'use strict';

    const CSS_TEXT = `
    .recap-hide-ui {
        #main-nav {
            display: none;
        }
        .body-footer {
            display: none;
        }
        
        .content-wrapper {
            padding-top: 0;
        }
    }
    #recap-takeover {
        display: grid;
        grid-template-columns: auto;
        justify-content: center;
        justify-items: center;
        padding: 1em;

        header div {
            display: flex;
            gap: 2em;
            align-items: center;
            margin: 5px 0;

            h2 {
                font-size: var(24px);
            }

            p {
                margin-bottom: 0;
            }

            input[type=number] {
                width: 3em;
            }
        }

        .recap-body {
            padding-top: 1em;
            display: grid;
            width: fit-content;
            grid-template-columns: repeat(10, 150px);
            justify-content: center;
            grid-gap: 0.5em;
        }

        .hide-title figure .title {
            display: none;
        }
        
        .hide-series figure .series-progress {
            display: none;
        }

        .hide-level figure .level {
            display: none;
        }
        
        .hide-dates figure .dates {
            display: none;
        }

        figure .level {
            cursor: default;
        }

        figure {
            margin: 0;
            text-align: center;
            padding: 0.3em;
        }

        figure img {
            height: 200px;
            aspect-ratio: keep;
        }

        span.series-progress {
            font-size: 10px;
            white-space: pre;
        }

        p.dates {
            font-size: 10px;
            margin-bottom: 0;
        }
    }
    #recap-dialog {
        padding: 1em;
        header {
            display: flex;
            align-items: center;
            margin-bottom: 1em;

            h2 {
                flex-grow: 1;
            }
        }
        #recap-settings-body {
            width: 900px;
            display: grid;
            grid-template-columns: 1fr 1fr auto;
            gap: 0.5em;

            .full-width {
                grid-column: 1/4;
            }

            h6.action {
                text-align: center;
            }

            .show-settings {
                display: flex;
                gap: 2em;
            }

            h4.full-width:not(:first-child) {
                margin-top: 1em;
            }
        }
    }
    `;

    const RECAP_DIALOG_ID = 'recap-dialog';

    const NATIVELY_LEVEL_RANGES = {
        'n5': { min: 0, max: 12 },
        'n4': { min: 13, max: 19 },
        'n3': { min: 20, max: 26 },
        'n2': { min: 27, max: 33 },
        'n1': { min: 34, max: 40 },
        'n1_plus': { min: 41, max: 999 },
    }

    const DEFAULT_COLORS = {
        'recap-background': 'rgba(255, 255, 255, 0)',
        'recap-text': '#161314',
        'manga': '#f7eafa',
        'manhwa': '#f7eafa',
        'comic': '#f7eafa',
        'novel': '#d9c1de',
        'light_novel': '#d9c1de',
        'childrens_book': '#d9c1de',
        'graded_reader': '#d9c1de',
        'textbook': '#d9c1de',
        'nonfiction': '#d9c1de',
        'movie': '#c7dec1',
        'tv_season': '#bce3b2',
        'other': '#d9d9d9',
    }

    const COLOR_NAME_LOOKUPS = {
        'recap-background': 'Recap Background',
        'recap-text': 'Text',
        'manga': 'Manga',
        'manhwa': 'Manhwa',
        'comic': 'Comic',
        'novel': 'Novel',
        'light_novel': 'Light Novel',
        'childrens_book': 'Children\'s Book',
        'graded_reader': 'Graded Reader',
        'textbook': 'Textbook',
        'nonfiction': 'Non-fiction',
        'movie': 'Movie',
        'tv_season': 'TV Season',
        'other': 'Other',
    };

    let state = {
        startDate: new Date("2024-01-01"),
        endDate: new Date("2024-12-31"),
        finishedFilter: {},
        settings: {
            columns: 10,
            nameReplacements: new Map(),
            colorOverrides: new Map(),
            defaultStartDate: "2024-01-01",
            defaultEndDate: "2024-12-31",
            show: {
                dates: true,
                title: true,
                seriesPos: true,
                level: false,
            }
        }
    };


    function saveSettings() {
        window.localStorage.setItem("araigoshi-recap-settings", JSON.stringify({
            columns: state.settings.columns,
            show: state.settings.show,
            nameReplacements: Object.fromEntries(state.settings.nameReplacements.entries()),
            colorOverrides: Object.fromEntries(state.settings.colorOverrides.entries()),
            defaultStartDate: state.startDate.toISOString(),
            defaultEndDate: state.endDate.toISOString(),
        }))
    }

    function loadSettings() {
        const savedSettingsStr = window.localStorage.getItem("araigoshi-recap-settings");
        if (savedSettingsStr === null) {
            return;
        }
        const savedSettings = JSON.parse(savedSettingsStr);
        if (savedSettings?.columns) {
            state.settings.columns = savedSettings.columns;
        }
        if (savedSettings?.nameReplacements) {
            state.settings.nameReplacements = new Map(Object.entries(savedSettings.nameReplacements));
        }
        if (savedSettings?.colorOverrides) {
            state.settings.colorOverrides = new Map(Object.entries(savedSettings.colorOverrides));
        }

        if (savedSettings?.show) {
            state.settings.show = {
                ...state.settings.show,
                ...savedSettings.show,
            };
        }

        if (savedSettings?.defaultStartDate) {
            state.startDate = new Date(savedSettings.defaultStartDate);
        }

        if (savedSettings?.defaultEndDate) {
            state.endDate = new Date(savedSettings.defaultEndDate);
        }
    }

    function loadStylesheet() {
        console.log('Loading recap stylesheet');
        if (document.getElementById('recap-styles') === null) {
            let styleSheet = document.createElement('style');
            styleSheet.id = 'recap-styles';
            styleSheet.textContent = CSS_TEXT;
            document.head.appendChild(styleSheet);
        }
        if (document.getElementById('recap-style-overrides') === null) {
            let overrideStyleSheet = document.createElement('style');
            overrideStyleSheet.id = 'recap-style-overrides';
            document.head.appendChild(overrideStyleSheet);
        }
    }

    function createDialog() {
        const el = document.createElement('dialog');
        el.id = RECAP_DIALOG_ID;
        document.body.appendChild(el);
        state.recapDialog = el;
    }

    function buildRequest(libraryType, page, finishedFilterId) {
        return {
            "page": page,
            "numOfPages": 1,
            "totalCount": 0,
            "itemType": "",
            "genre": "",
            "q": "",
            "bookProviders": "",
            "watchProviders": "",
            "location": "",
            "minLevel": "",
            "maxLevel": "",
            "levelFilter": "",
            "bookType": "",
            "tagFilters": "",
            "loading": false,
            "onlyFree": false,
            "excludeTemporaryRatings": false,
            "excludeLibrary": false,
            "wanikani": false,
            "mobileFilter": false,
            "tags": "",
            "includeSpoilers": false,
            "includeMinorElements": false,
            "includeMajorElementsOnly": false,
            "itemTypes": [],
            "genreOptions": [],
            "openSeries": [],
            "error": "",
            "libraryType": libraryType,
            "sort": "-recent",
            "subs": "",
            "favorite": false,
            "resultActive": null,
            "listFilter": finishedFilterId,
            "lists": [],
            "customTags": "",
            "collapseSeries": false,
            "pageSize": 50,
            "needsBackendSearch": false
        };
    }

    function isItemInRange(startDate, endDate) {
        return function (item) {
            const itemFinishedUnixDate = item.dateFinishedData?.timestampSeconds;
            if (!itemFinishedUnixDate) {
                return false;
            }

            const itemFinishedDate = new Date(itemFinishedUnixDate * 1000);
            const nextDay = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate() + 1);

            return startDate <= itemFinishedDate && itemFinishedDate < nextDay;
        }
    }

    function jsToIsoDate(jsDate) {
        return jsDate.toISOString().substring(0, 10);
    }

    function transformItemDate(item, key) {
        if (!item[key]) {
            return 'Unknown';
        }
        const unixDate = item[key].timestampSeconds;
        const jsDate = new Date(unixDate * 1000);
        return jsToIsoDate(jsDate);
    }

    function calculateRatingGroup(rating) {
        for (const [key, def] of Object.entries(NATIVELY_LEVEL_RANGES)) {
            if (def.min <= rating && rating <= def.max) {
                return key;
            }
        }
        return '';
    }

    function generateItemSummary(item, seriesCache) {
        let adjustedName = item.item.title;
        for (const [src, replacement] of state.settings.nameReplacements.entries()) {
            adjustedName = adjustedName.replace(src, replacement);
        }
        const simpleType = item.item.mediaType.replace(' ', '_').toLowerCase();
        return {
            'image': item.item.image?.url,
            'url': item.item.url,
            'level': 'L' + (item.ratingLevel ? '' + item.ratingLevel : '?') + (item.item.rating?.temporary ? '?' : ''),
            'levelGroup': calculateRatingGroup(item.ratingLevel),
            'seriesPos': item.item.seriesOrder || 1,
            'seriesLength': item.item.seriesId ? (seriesCache[item.item.seriesId]?.numOfItems || 0) : 1,
            'name': adjustedName,
            'started': transformItemDate(item, 'dateStartedData'),
            'finished': transformItemDate(item, 'dateFinishedData'),
            'finishedUnix': item.dateFinishedData?.timestampSeconds || 0,
            'type': simpleType,
        }
    }

    function itemElement(parsedItem) {
        const figure = document.createElement('figure');
        figure.classList.add(`item-${parsedItem['type']}`)
        figure.innerHTML = `
        <a href="${parsedItem['url']}"><img src="${parsedItem['image']}" /></a>
        <figcaption>
            <span class="level ${parsedItem['levelGroup']}">${parsedItem['level']}</span>
            <a class="title" href="${parsedItem['url']}">${parsedItem['name']}</a>
            <span class="series-progress">(${parsedItem['seriesPos']} / ${parsedItem['seriesLength']})</span>
            <p class="dates"><span class="started">${parsedItem['started']}</span> - <span class="ended">${parsedItem['finished']}</span></p>
        </figcaption>
        `;
        return figure;
    }

    async function findFinishedFilter(libraryType) {
        if (state.finishedFilter[libraryType]) {
            return state.finishedFilter[libraryType];
        }

        const listsResult = await fetch(`/item-list-api?user=${state.user}&library_type=${libraryType}`);
        const resultBody = await listsResult.json();
        const listFilter = resultBody.find(listFilter => listFilter.correlatedStatus === 'finished');
        if (listFilter) {
            state.finishedFilter[libraryType] = listFilter.id;
            return listFilter.id;
        } else {
            state.finishedFilter[libraryType] = '';
            return '';
        }
    }

    function generateItemList(nativelyData) {
        const dateFilter = isItemInRange(state.startDate, state.endDate);
        const items = nativelyData.results
            .filter(item => item.item != null)
            .filter(dateFilter);
        return items.map(item => generateItemSummary(item, nativelyData.seriesCache));
    }

    function setDialogToLoadingText(text) {
        state.recapDialog.innerHTML = `
            <h2>Loading Natively Recap</h2>
            <p>${text}</p>
        `
    }

    function addSettingsReplacementRow(recapBody, addReplButton, src, dest) {
        const srcInput = document.createElement('input');
        srcInput.type = 'text';
        srcInput.value = src;
        srcInput.classList.add('recap-replacement-src');
        recapBody.insertBefore(srcInput, addReplButton);

        const destInput = document.createElement('input');
        destInput.type = 'text';
        destInput.value = dest;
        destInput.classList.add('recap-replacement-dest');
        recapBody.insertBefore(destInput, addReplButton);

        const deleteButton = document.createElement('button');
        deleteButton.type = 'button';
        deleteButton.textContent = 'X';
        deleteButton.addEventListener('click', (evt) => {
            recapBody.removeChild(srcInput);
            recapBody.removeChild(destInput);
            recapBody.removeChild(deleteButton);
            evt.stopPropagation();
        });
        recapBody.insertBefore(deleteButton, addReplButton);
    }

    function addColourConfigRow(recapBody, key, currentValue) {
        const label = document.createElement('p');
        label.textContent = COLOR_NAME_LOOKUPS[key];
        recapBody.appendChild(label);

        const textInput = document.createElement('input');
        textInput.type = 'text';
        textInput.dataset.colorKey = key;
        textInput.value = currentValue;
        textInput.classList.add('color-text-code');
        recapBody.appendChild(textInput);

        const colorInput = document.createElement('input');
        colorInput.type = 'color';
        colorInput.value = currentValue;
        colorInput.addEventListener('change', (evt) => {
            textInput.value = evt.target.value;
        });
        recapBody.appendChild(colorInput);
    }

    function showSettingsDialog() {
        const initialTitleShowState = state.settings.show.title ? 'checked' : ''
        const initialSeriesShowState = state.settings.show.seriesPos ? 'checked' : ''
        const initialDatesShowState = state.settings.show.dates ? 'checked' : ''
        const initialLevelShowState = state.settings.show.level ? 'checked' : ''
        state.recapDialog.innerHTML = `
            <header>
                <h2>Recap Settings</h2>
                <button type="button" id="recap-dialog-close-button">X</button>
            </header>
            <main id="recap-settings-body">
                <h4 class="full-width">Show/Hide Info</h4>
                <div class="full-width show-settings">
                    <div><input type="checkbox" id="recap-show-title" name="recap-show-title" ${initialTitleShowState}> <label for="recap-show-title">Title</label></div>
                    <div><input type="checkbox" id="recap-show-series" name="recap-show-series" ${initialSeriesShowState}> <label for="recap-show-series">Series Position</label></div>
                    <div><input type="checkbox" id="recap-show-dates" name="recap-show-dates" ${initialDatesShowState}> <label for="recap-show-dates">Dates</label></div>
                    <div><input type="checkbox" id="recap-show-level" name="recap-show-level" ${initialLevelShowState}> <label for="recap-show-level">Level</label></div>
                </div>
                <h4 class="full-width">Name Replacements</h4>
                <p class="full-width">
                    Do some of your shows/books have long/ugly names? Make them shorter
                </p>
                <h6>Text to change</h6><h6>Replacement text</h6><h6 class="material-icons-outlined action">delete</h6>
                <div class="full-width" id="recap-add-more-replacements">
                    <button type="button" id="add-more-button">+ Add more</button>
                </div>
                <h4 class="full-width">Custom Colors</h4>
                <h6>Type</h6><h6>Colour</h6><h6 class="action">Prv</h6>
            </main>
        `;
        const recapBody = state.recapDialog.querySelector('#recap-settings-body');
        const addReplButton = state.recapDialog.querySelector('#recap-add-more-replacements');
        const nameReplacements = state.settings.nameReplacements.size > 0 ? [...state.settings.nameReplacements] : [["", ""]];
        for (const [src, dest] of nameReplacements) {
            addSettingsReplacementRow(recapBody, addReplButton, src, dest);
        }
        state.recapDialog.querySelector('#add-more-button').addEventListener('click', () => {
            addSettingsReplacementRow(recapBody, addReplButton, "", "");
        });
        const resolvedColors = resolveCurrentColors();
        for (const [key, color] of resolvedColors.entries()) {
            addColourConfigRow(recapBody, key, color);
        }
        state.recapDialog.querySelector('#recap-dialog-close-button').addEventListener('click', () => {
            const replacementSrcs = [...recapBody.querySelectorAll('.recap-replacement-src').values()].map(x => x.value);
            const replacementValues = [...recapBody.querySelectorAll('.recap-replacement-dest').values()].map(x => x.value);

            let newNameReplacements = new Map();
            for (let i = 0; i < replacementSrcs.length; i++) {
                let src = replacementSrcs[i];
                let value = replacementValues[i] || "";

                if (src !== "") {
                    newNameReplacements.set(src, value);
                }
            }
            state.settings.nameReplacements = newNameReplacements;
            let colorOverrides = new Map();
            for (const element of recapBody.querySelectorAll('.color-text-code')) {
                const key = element.dataset.colorKey;
                const value = element.value;
                colorOverrides.set(key, value);
            }
            state.settings.colorOverrides = colorOverrides;
            state.settings.show.title = document.querySelector('#recap-show-title').checked;
            state.settings.show.seriesPos = document.querySelector('#recap-show-series').checked;
            state.settings.show.dates = document.querySelector('#recap-show-dates').checked;
            state.settings.show.level = document.querySelector('#recap-show-level').checked;
            saveSettings();
            updateRecapBody();
            updateStyleOverrides();
            state.recapDialog.close();
        });
        state.recapDialog.showModal();
    }

    async function fetchLibrary(libraryType) {
        setDialogToLoadingText(`Loading filters for ${libraryType}`);
        const filterId = await findFinishedFilter(libraryType);
        let seriesCache = {};
        let results = [];
        let totalPages = 1;
        let currentPage = 0;
        do {
            setDialogToLoadingText(`Loading ${libraryType} page ${currentPage + 1} of ${totalPages}`);
            const csrfToken = document.querySelector('meta[name=csrf-token]').getAttribute('content');
            const response = await fetch(`/api/item-library-search-api/${state.user}/`, {
                method: 'POST',
                headers: {
                    'content-type': 'application/json',
                    'X-CSRFToken': csrfToken,
                },
                body: JSON.stringify(buildRequest(libraryType, currentPage + 1, filterId)),
            });
            const responseBody = await response.json();
            if (responseBody.numOfPages) {
                totalPages = responseBody.numOfPages;
            }
            currentPage++;
            for (const result of responseBody.results) {
                if (result.item) {
                    results.push(result);
                } else if (result.itemList) {
                    for (const listItem of result.results) {
                        results.push(listItem.data);
                    }
                }
            }
            if (responseBody.seriesCache) {
                seriesCache = { ...seriesCache, ...responseBody.seriesCache };
            }
        } while (currentPage < totalPages);
        return {
            seriesCache,
            results,
        };
    }

    async function loadData() {
        setDialogToLoadingText('Building your recap');
        state.recapDialog.showModal();
        if (!state.rawBookData) {
            console.log('Loading data');
            state.rawBookData = await fetchLibrary('books');
        }
        if (!state.rawVideoData) {
            state.rawVideoData = await fetchLibrary('videos');
        }
        const bookList = generateItemList(state.rawBookData);
        const videoList = generateItemList(state.rawVideoData);
        const parsedData = [...bookList, ...videoList];
        parsedData.sort((a, b) => a.finishedUnix - b.finishedUnix);
        state.recapDialog.close();
        console.log('Loaded item data');
        console.log(parsedData);
        return parsedData;
    }

    async function updateRecapBody() {
        const data = await loadData();
        let recapBodies = document.querySelectorAll('.recap-body');
        for (const recapBody of recapBodies) {
            recapBody.innerHTML = '';
            for (const item of data) {
                recapBody.appendChild(itemElement(item));
            }
        }
    }

    function addLink() {
        const navbar = document.querySelector('.navbar-nav');
        const lastLink = document.querySelector('.last-link');

        const recapLink = document.createElement('a');
        recapLink.textContent = 'Recap';
        recapLink.classList.add('nav-link');
        recapLink.href = '#recap';
        recapLink.addEventListener('click', showRecap);
        navbar.insertBefore(recapLink, lastLink);
    }

    function resolveCurrentColors() {
        const resolvedColors = new Map();
        for (const [k, v] of Object.entries(DEFAULT_COLORS)) {
            resolvedColors.set(k, v);
        }
        for (const [k, v] of state.settings.colorOverrides.entries()) {
            resolvedColors.set(k, v);
        }

        return resolvedColors;
    }

    function updateStyleOverrides() {
        const resolvedColors = resolveCurrentColors();
        const itemStyles = resolvedColors.entries().map(([key, value]) => `
            .item-${key} {
                background: ${value};
            }
        `).reduce((a, b) => a + '\n' + b);

        const textColor = resolvedColors.get('recap-text');
        const bgColor = resolvedColors.get('recap-background');
        const styles = `
        #recap-takeover {
            .recap-body {
                padding: 1em;
                grid-template-columns: repeat(${state.settings.columns}, 150px);
                color: ${textColor};
                background-color: ${bgColor};
            }
            .recap-body a {
                color: ${textColor};
            }
            ${itemStyles}
        }
        `;
        document.querySelector('#recap-style-overrides').innerHTML = styles;
        if (document.querySelector('.recap-body')) {
            document.querySelector('.recap-body').classList.toggle('hide-title', !state.settings.show.title);
            document.querySelector('.recap-body').classList.toggle('hide-series', !state.settings.show.seriesPos);
            document.querySelector('.recap-body').classList.toggle('hide-dates', !state.settings.show.dates);
            document.querySelector('.recap-body').classList.toggle('hide-level', !state.settings.show.level);
        }
    }

    function createRecapElement(elementType, id) {
        const recapEl = document.createElement(elementType);
        recapEl.id = id;
        recapEl.innerHTML = `
            <header>
                <div>
                    <h3>${state.user}'s Natively Recap</h3>
                    <button class="hide-natively-ui">Toggle Natively UI</button>
                </div>
                <div>
                    <p>Date range</p>
                    <input type="date" id="recap-start-date" value="${jsToIsoDate(state.startDate)}" min="2000-01-01" max="2050-12-31" />
                    <input type="date" id="recap-end-date" value="${jsToIsoDate(state.endDate)}" min="2000-01-01" max="2025-12-31" />
                    <p>Columns</p>
                    <input type="number" id="recap-columns" value="${state.settings.columns}" />
                    <a class="material-icons-outlined" id="recap-settings-link" href="#">settings</a>
                </div>
            </header>

            <main class="recap-body">
            </main>
        `;
        recapEl.querySelector('.hide-natively-ui').addEventListener('click', (evt) => {
            document.body.classList.toggle('recap-hide-ui');
        });
        recapEl.querySelector('#recap-start-date').addEventListener('change', (evt) => {
            state.startDate = new Date(evt.target.valueAsNumber);
            updateRecapBody();
            saveSettings();
            evt.preventDefault();
        });
        recapEl.querySelector('#recap-end-date').addEventListener('change', (evt) => {
            state.endDate = new Date(evt.target.valueAsNumber);
            updateRecapBody();
            saveSettings();
        });
        recapEl.querySelector('#recap-columns').addEventListener('change', (evt) => {
            state.settings.columns = evt.target.value;
            saveSettings();
            updateStyleOverrides();
        });
        recapEl.querySelector('#recap-settings-link').addEventListener('click', (evt) => {
            showSettingsDialog();
            evt.stopPropagation();
        });
        recapEl.querySelector('.recap-body').classList.toggle('hide-title', !state.settings.show.title);
        recapEl.querySelector('.recap-body').classList.toggle('hide-series', !state.settings.show.seriesPos);
        recapEl.querySelector('.recap-body').classList.toggle('hide-dates', !state.settings.show.dates);
        recapEl.querySelector('.recap-body').classList.toggle('hide-level', !state.settings.show.level);
        document.body.appendChild(recapEl);
        return recapEl;
    }

    function findCurrentUser() {
        const hashParams = document.location.hash.split('?')[1]?.split('&') || [];
        let hashParamsLookup = {};
        for (const hashParam of hashParams) {
            let parts = hashParam.split('=');
            let key = parts[0];
            let value = parts[1] || "";
            hashParamsLookup[key] = value;
        }
        state.user = hashParamsLookup["recap-user"] || window.gs.initialState.user.username;
    }

    function showRecap() {
        const recapEl = createRecapElement('div', 'recap-takeover');
        document.querySelector('.content-wrapper').replaceChildren(recapEl);
        updateRecapBody();
    }

    function init() {
        createDialog();
        loadStylesheet();
        findCurrentUser();
        addLink();
        loadSettings();
        updateStyleOverrides();
        window.RECAP_STATE = state;
        if (window.location.hash.startsWith('#recap')) {
            showRecap();
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();