// ==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.2
// @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;
}
figure {
margin: 0;
text-align: center;
}
.item-manga {
background: #f7eafa;
}
.item-novel {
background: #d9c1de;
}
.item-anime {
background: #c7dec1;
}
.item-movie {
background: #bce3b2;
}
figure img {
height: 200px;
aspect-ratio: keep;
}
span.series-progress {
font-size: 10px;
white-space: pre;
}
p.dates {
font-size: 10px;
}
}
`;
let state = {
startDate: new Date("2024-01-01"),
endDate: new Date("2024-12-31"),
columns: 10,
};
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 buildRequest(libraryType) {
return {
"page": 1,
"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": null,
"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);
return startDate <= itemFinishedDate && itemFinishedDate <= endDate;
}
}
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 generateItemSummary(item, itemType, seriesCache) {
return {
'image': item.item.image?.url,
'seriesPos': item.item.seriesOrder || 1,
'seriesLength': item.item.seriesId ? seriesCache[item.item.seriesId].numOfItems : 1,
'name': item.item.title,
'started': transformItemDate(item, 'dateStartedData'),
'finished': transformItemDate(item, 'dateFinishedData'),
'finishedUnix': item.dateFinishedData?.timestampSeconds || 0,
'type': itemType,
}
}
function itemHtmlSnippet(parsedItem) {
return `
<figure class="item-${parsedItem['type']}">
<img src="${parsedItem['image']}" />
<figcaption>
${parsedItem['name']} <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>
</figure>
`
}
function generateItemList(nativelyData, itemType, nativelyItemTypes) {
const itemTypes = nativelyItemTypes ? nativelyItemTypes : [itemType];
const dateFilter = isItemInRange(state.startDate, state.endDate);
const typeFilter = (item) => itemTypes.includes(item.item.mediaType);
const items = nativelyData.results.filter(typeFilter).filter(dateFilter);
return items.map(item => generateItemSummary(item, itemType, nativelyData.seriesCache));
}
async function fetchLibrary(libraryType) {
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)),
});
return response.json();
}
async function loadData() {
if (!state.rawBookData) {
console.log('Loading data');
state.rawBookData = await fetchLibrary('books');
}
if (!state.rawVideoData) {
state.rawVideoData = await fetchLibrary('videos');
}
const manga = generateItemList(state.rawBookData, 'manga');
const novels = generateItemList(state.rawBookData, 'novel', ['Light novel', 'light_novel', 'childrens_book', 'novel'])
const movies = generateItemList(state.rawVideoData, 'movie');
const anime = generateItemList(state.rawVideoData, 'movie', ['tv_season']);
const parsedData = [...manga, ...novels, ...movies, ...anime];
parsedData.sort((a, b) => a.finishedUnix - b.finishedUnix);
console.log('Loaded item data');
console.log(parsedData);
return parsedData;
}
async function generateRecapBody() {
const data = await loadData();
const htmlComponents = data.map(itemHtmlSnippet);
return htmlComponents.join('\n');
}
async function updateRecapBody() {
let recapBodies = document.querySelectorAll('.recap-body');
let bodyContent = await generateRecapBody();
recapBodies.forEach(body => body.innerHTML = bodyContent);
}
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 updateStyleOverrides() {
const styles = `
#recap-takeover {
.recap-body {
grid-template-columns: repeat(${state.columns}, 150px);
}
}
`;
document.querySelector('#recap-style-overrides').innerHTML = styles;
}
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)}" />
<input type="date" id="recap-end-date" value="${jsToIsoDate(state.endDate)}" />
<p>Columns</p>
<input type="number" id="recap-columns" value="${state.columns}" />
</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();
});
recapEl.querySelector('#recap-end-date').addEventListener('change', (evt) => {
state.endDate = new Date(evt.target.valueAsNumber);
updateRecapBody();
});
recapEl.querySelector('#recap-columns').addEventListener('change', (evt) => {
state.columns = evt.target.value;
updateStyleOverrides();
});
document.body.appendChild(recapEl);
return recapEl;
}
function findCurrentUser() {
var navLink = document.querySelector('a[href^="/user"]');
state.user = new URL(navLink.href).pathname.split('/')[2];
}
function showRecap() {
const recapEl = createRecapElement('div', 'recap-takeover');
document.querySelector('.content-wrapper').replaceChildren(recapEl);
updateRecapBody();
}
function init() {
loadStylesheet();
findCurrentUser();
addLink();
window.RECAP_STATE = state;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();