Last active
June 4, 2022 09:25
-
-
Save patarapolw/42e5337921fae9c596b02bfe431a1b18 to your computer and use it in GitHub Desktop.
WaniKani Community Like Counter from https://community.wanikani.com/t/userscript-like-counter/27389 + adapted for mobile
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name WaniKani Forums: Like counter | |
// @namespace http://tampermonkey.net/ | |
// @version 3.1.7 | |
// @description Keeps track of the likes you've used and how many you have left... supposedly. | |
// @author Kumirei | |
// @include https://community.wanikani.com* | |
// @grant none | |
// ==/UserScript== | |
;(function ($) { | |
// SETTINGS | |
const settings = { | |
update_interval: 10, // Interval (minutes) for fetching summary page data and likes | |
lifetime_purple: false, // Set to true for purple info bubbles | |
hideReceived: false, | |
} | |
function getUsername() { | |
return ($('#current-user a').attr('href') || '').split('/u/')[1] || '' | |
} | |
// Global variable | |
let LC = { | |
stored: { | |
zero: [], | |
full: [], | |
received: [], | |
summary: { | |
last_update: '1970-01-01T00:00:00.000Z', | |
likes_given: 0, | |
likes_received: 0, | |
days_visited: 0, | |
max: 200, // 200 is default for *regulars* | |
}, | |
day: { | |
given: [], | |
received: 0, | |
}, | |
}, | |
elems: { | |
received: null, | |
given: null, | |
next: null, | |
}, | |
} | |
// Update LC | |
Promise.all([update_stored(), update_summary()]).then(update) | |
setInterval(update_all, settings.update_interval * 60 * 1000, LC) | |
update_all() | |
// Update the next like timer every second | |
setInterval(update_next, 1000) | |
// Install | |
add_CSS() | |
add_display() | |
// Update every time a like is used | |
$('body').on('click', '.post-stream .toggle-like', update) | |
// Updates everything | |
async function update(event) { | |
// Update displayed count immediately | |
if (event.type) { | |
const old_count = Number(LC.elems.given.children().text()) | |
const new_count = old_count + ($(event.target).closest('.widget-button').hasClass('has-like') ? 1 : -1) | |
LC.elems.given.children().text(new_count) | |
} | |
} | |
// Fetches the data of LC.stored from localStorage | |
function update_stored() { | |
LC.stored = Object.assign(LC.stored, JSON.parse(localStorage.getItem('LCstored')) || {}) | |
} | |
// Saves the LC.stored data to localStorage | |
function save_stored() { | |
localStorage.setItem('LCstored', JSON.stringify(LC.stored)) | |
} | |
// Updates summary info and likes used/received | |
async function update_all() { | |
update_stored() | |
const now = new Date(Date.now() - settings.update_interval * 60 * 1000).toISOString() | |
if (LC.stored.summary.last_update < now) { | |
//alert('Updating' + new Date().toISOString() + '\n\n' + LC.stored.summary.last_update) | |
await update_summary() | |
await update_day() | |
save_stored() | |
update_display() | |
} | |
} | |
// Updates the LC variable with info from the summary page | |
async function update_summary() { | |
const username = getUsername() | |
const f = await fetch(`https://community.wanikani.com/u/${username}/summary`, { | |
headers: { | |
accept: 'application/json, text/javascript, */*; q=0.01', | |
'x-requested-with': 'XMLHttpRequest', | |
}, | |
}) | |
if (f.status === 200) { | |
const data = await f.json() | |
const max = 50 * (1 + data.badges[0].id) | |
const { likes_given, likes_received, days_visited } = data.user_summary | |
LC.stored.summary = { | |
likes_given, | |
likes_received, | |
days_visited, | |
max, | |
last_update: new Date().toISOString(), | |
} | |
const now = Date.now() | |
LC.stored.received.push([now, likes_received]) | |
LC.stored.received = LC.stored.received.filter((a) => a[0] >= now - 24 * 60 * 60 * 1000) | |
} else console.warn(`[LIKE COUNTER] Error ${f.status}: There was an error fetching user summary`) | |
} | |
// Updates the likes given and received in the last 24 hours | |
async function update_day() { | |
const msday = 24 * 60 * 60 * 1000 | |
const now = Date.now() | |
const username = getUsername() | |
const summary = LC.stored.summary | |
const day = LC.stored.day | |
day.given = (await fetch_likes(username, 1, 24)).reverse() | |
//day.received = (await fetch_likes(username, 2, 24)).reverse() | |
day.received = LC.stored.received[LC.stored.received.length - 1][1] - LC.stored.received[0][1] || 0 | |
if (day.given.length === summary.max && (LC.stored.zero[LC.stored.zero.length - 1] || 0) < now - msday) { | |
LC.stored.zero.push(now) | |
} | |
if (day.given.length === 0 && (LC.stored.full[LC.stored.full.length - 1] || 0) < now - msday) { | |
LC.stored.full.push(now) | |
} | |
} | |
// Fetches likes from Discourse api | |
async function fetch_likes(username, actionType, hoursBack) { | |
const time = new Date(Date.now() - hoursBack * 60 * 60 * 1000).toISOString() | |
let offset = 0 | |
let fetched = [] | |
let keep_fetching = true | |
while (keep_fetching) { | |
const f = await fetch( | |
`https://community.wanikani.com/user_actions.json?offset=0&username=${username}&filter=${actionType}&offset=${offset}`, | |
) | |
if (f.status !== 200) { | |
console.warn(`[LIKE COUNTER] Error ${f.status}: There was an error fetching user likes`) | |
break | |
} | |
const actions = (await f.json()).user_actions | |
for (let item of actions) { | |
const date = item.created_at | |
if (date >= time) { | |
fetched.push(Date.parse(item.created_at)) | |
} else { | |
keep_fetching = false | |
break | |
} | |
} | |
offset += 30 | |
} | |
return fetched | |
} | |
// Adds the bubbles to the header | |
function add_display() { | |
// START code by rfindley | |
if (is_dark_theme()) $('body').attr('theme', 'dark') | |
else $('body').attr('theme', 'light') | |
// Wait for the nav | |
const wk_app_nav = $('.wanikani-app-nav').closest('.container') | |
if (wk_app_nav.length === 0) { | |
setTimeout(add_display, 200) | |
return | |
} | |
// Attach the Dashboard menu to the stay-on-top menu. | |
const top_menu = $('.d-header') | |
const main_content = $('#main-outlet') | |
$('body').addClass('float_wkappnav') | |
wk_app_nav.addClass('wanikani-app-nav-container') | |
wk_app_nav.find('li').each((_, el) => { | |
const $el = $(el) | |
if (!$el.attr('data-name')) { | |
$el.attr('data-name', 'original') | |
} | |
}) | |
top_menu.find('>.wrap > .contents:eq(0)').after(wk_app_nav) | |
// Adjust the main content's top padding, so it won't be hidden under the new taller top menu. | |
const main_content_toppad = Number(main_content.css('padding-top').match(/[0-9]*/)[0]) | |
main_content.css('padding-top', main_content_toppad + 25 + 'px') | |
// END code by rfindley | |
LC.elems = { | |
// dashboard: $( | |
// '<li class="show-on-small-screen">' + | |
// '<a href="https://www.wanikani.com" target="_blank" rel="noopener noreferrer">WaniKani</a>' + | |
// '</li>', | |
// ), | |
received: $( | |
'<li data-highlight="true" data-name="likes-received"' + | |
(settings.hideReceived ? ' style="display:none"' : '') + | |
'>Likes Received<span id="likes_received" class="dashboard_bubble">0</span></li>', | |
), | |
given: $( | |
'<li data-highlight="true" data-name="likes-left">Likes Left<span id="likes_given" class="dashboard_bubble">0</span></li>', | |
), | |
next: $( | |
'<li data-highlight="true" data-name="likes-next">Next Like<span id="next_like" class="dashboard_bubble">0</span></li>', | |
), | |
} | |
$('.wanikani-app-nav ul').append([LC.elems.received, LC.elems.given, LC.elems.next]) | |
update_display() | |
} | |
// Updates the counts and the hover info | |
function update_display() { | |
const msday = 24 * 60 * 60 * 1000 | |
const msh = msday / 24 | |
const now = Date.now() | |
const { received, given, next } = LC.elems | |
const summary = LC.stored.summary | |
const day = LC.stored.day | |
// Update counts | |
received.children().text(day.received) | |
given.children().text(summary.max - day.given.length) | |
next.children().text(time_left(day.given[0] + msday)) | |
day.given.length < summary.max ? $('body').removeClass('no-likes') : $('body').addClass('no-likes') | |
// Update hover info | |
received.attr( | |
'title', | |
`${day.received.toLocaleString()} likes received in past 24h` + | |
`\n${Math.round( | |
summary.likes_received / summary.days_visited, | |
).toLocaleString()} likes received per day visited` + | |
`\n${summary.likes_received.toLocaleString()} total likes received`, | |
) | |
given.attr( | |
'title', | |
`${day.given.length.toLocaleString()} likes given in past 24h` + | |
`\n${Math.round( | |
summary.likes_given / summary.days_visited, | |
).toLocaleString()} likes given per day visited` + | |
`\n${summary.likes_given.toLocaleString()} total likes given` + | |
`\n\n${LC.stored.zero.length.toLocaleString()} times have you ran out` + | |
`\n${comma( | |
Math.floor((now - (LC.stored.zero[LC.stored.zero.length - 1] || now)) / msday), | |
)} days since you last ran out` + | |
`\n\n${LC.stored.full.length.toLocaleString()} times have you had full likes` + | |
`\n${comma( | |
Math.floor((now - (LC.stored.full[LC.stored.full.length - 1] || now)) / msday), | |
)} days since you last had full likes`, | |
) | |
let hours = Array(24) | |
.fill(0) | |
.map( | |
(_, i) => | |
day.given.filter((like) => like + msday > now + i * msh && like + msday < now + (i + 1) * msh) | |
.length, | |
) | |
const next_like = new Date(day.given[0] + msday) | |
next.attr( | |
'title', | |
`Next like at ${next_like.getHours()}:${ | |
(next_like.getMinutes() < 10 ? '0' : '') + next_like.getMinutes() | |
}` + `\n\nLikes replenishing in ${hours.reduce((a, c, i) => (c == 0 ? a : `${a}\n${i + 1}h: ${c}`), ``)}`, | |
) | |
} | |
function comma(n) { | |
return n.toLocaleString('en-US') | |
} | |
// Update the timer for the next like | |
function update_next() { | |
const msday = 24 * 60 * 60 * 1000 | |
const yesterday = Date.now() - msday | |
const day = LC.stored.day | |
const given = day.given.length | |
day.given = day.given.filter((t) => t > yesterday) | |
// If likes have been used or expired update whole display | |
if (given !== day.given.length) update_display() | |
// Else just update the timer | |
else LC.elems.next.children().text(time_left(day.given[0] + msday)) | |
} | |
// Adds the CSS | |
function add_CSS() { | |
let bubble_color = settings.lifetime_purple ? 'rgb(213, 128, 255)' : '#6cf' | |
$('head').append( | |
' <style id=like_counter>' + | |
' body[theme="dark"] .wanikani-app-nav ul li {color:#999;}' + | |
' li[data-highlight="true"] span.dashboard_bubble {background-color: ' + | |
bubble_color + | |
' !important;}' + | |
' bbody.no-likes .like > .fa.d-icon-d-unliked {color: red !important}' + | |
' .wanikani-app-nav > ul {display: flex;}' + | |
' .wanikani-app-nav li[data-name="likes-received"] {order: 1;}' + | |
' .wanikani-app-nav li[data-name="likes-left"] {order: 2;}' + | |
' .wanikani-app-nav li[data-name="likes-next"] {order: 3;}' + | |
' .wanikani-app-nav li[data-name="lesson_count"],' + | |
' .wanikani-app-nav li[data-name="review_count"],' + | |
' .wanikani-app-nav li[data-name="next_review"] {order: 0;}' + | |
' .float_wkappnav .d-header {padding-bottom: 2em;}' + | |
' .float_wkappnav .d-header {height: 4em !important;}' + | |
' .float_wkappnav .d-header .title {height:4em;}' + | |
' .float_wkappnav .wanikani-app-nav-container {border-top:1px solid #ccc; line-height:2em;}' + | |
' .float_wkappnav .wanikani-app-nav ul {padding-bottom:0; margin-bottom:0; border-bottom:inherit;}' + | |
' .dashboard_bubble {color:#fff; background-color:#bdbdbd; font-size:0.8em; border-radius:0.5em; padding:0 6px; margin:0 0 0 4px; font-weight:bold;}' + | |
' li[data-highlight="true"] .dashboard_bubble {background-color:#6cf;}' + | |
' body[theme="dark"] .dashboard_bubble {background-color:#646464;}' + | |
' body[theme="dark"] li[data-highlight="true"] .dashboard_bubble {color:#000; background-color:#6cf;}' + | |
' body[theme="dark"] .wanikani-app-nav[data-highlight-labels="true"] li[data-highlight="true"] a {color:#6cf;}' + | |
' body[theme="dark"] .wanikani-app-nav ul li a {color:#999;}' + | |
'</style>', | |
).append( | |
'<style id="responsive-wanikani-app-nav-list-header">' + | |
'.wanikani-app-nav .show-on-small-screen {display: none;}' + | |
'@media screen and (max-width: 799px) {' + | |
' .wanikani-app-nav {margin-top: 0; float: right;}' + | |
' .wanikani-app-nav .hide-on-small-screen,' + | |
' .wanikani-app-nav li[data-name="original"] {display: none !important;}' + | |
' .wanikani-app-nav .show-on-small-screen {display: block !important;}' + | |
'}'+ | |
'</style>' | |
) | |
} | |
// Returns a string with the time remaining until the given date | |
function time_left(date) { | |
if (!date) return 'N/A' | |
const seconds = (date - Date.now()) / 1000 | |
const s = Math.floor((seconds % 3600) % 60) | |
const sr = Math.round((seconds % 3600) % 60) | |
const m = Math.floor(((seconds - s) / 60) % 60) | |
const mr = Math.round(((seconds - s) / 60) % 60) | |
const h = Math.floor((seconds - s - m * 60) / 3600) | |
const hr = Math.round((seconds - s - m * 60) / 3600) | |
if (h != 0) return hr + 'h' | |
if (m != 0) return mr + 'm' | |
if (s != 0) return sr + 's' | |
} | |
// Checks whether a dark theme is used | |
function is_dark_theme() { | |
// Grab the <html> background color, average the RGB. If less than 50% bright, it's dark theme. | |
return ( | |
$('html') | |
.css('background-color') | |
.match(/\((.*)\)/)[1] | |
.split(',') | |
.slice(0, 3) | |
.map((str) => Number(str)) | |
.reduce((a, i) => a + i) / | |
(255 * 3) < | |
0.5 | |
) | |
} | |
})(window.jQuery) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
TODO:
getUsername()
still doesn't work on mobile. Username has to be manually added.