// ==UserScript== // @name Ubooquity Read Markers // @namespace https://github.com/ToxicFrog/misc // @description Adds unopened/in-progress/read markers to the Ubooquity comic server // @include https://my.ubooquity.server/comics/* // @version 0.2 // ==/UserScript== // Annotations are as follows // 5 📕 Book unopened, 5 pages // 2/5 📖 Book in progress, 2 pages of 5 read // ✓ Book finished // ? 📁 Directory status unknown // 5 📁 Directory contains 5 unfinished books // 2/5 📂 Directory contains 2 finished books of 5 total // 5 ✓ All 5 books in directory finished. // Convenience functions. filter/map work on any iterable, but are only defined as // methods on Array for some reason. function filter(xs, f) { return Array.prototype.filter.call(xs, f); } function map(xs, f) { return Array.prototype.map.call(xs, f); } function sleep(ms) { return new Promise(resolve => setTimeout(result => resolve(result), ms)); } // Ubooq isn't always served from /, so this lets us detect what the base URL is. // Ubooq URLs look like: // $baseURL/comics/$id // $baseURL/comicreader/reader.html#$state // or, for the book library, replace "comic" with "book" let baseURL = window.location.pathname.match("(/.*)/((?:comics|books)/[0-9]+|(?:comic|book)reader/reader)"); if (baseURL) { baseURL = baseURL[1]; } else { baseURL = window.location.pathname.replace(/\/+$/, ''); } console.log("Detected baseURL as ", baseURL); // TODO: a lot of this information we can get from the contents of the Scope, // take a look at the output of getScope() sometime. let isBook = window.location.pathname.match('/books/([0-9]+)'); if (isBook) { var detailsURL = baseURL + '/bookdetails/'; var bookmarkURL = baseURL + '/user-api/bookmark?isBook=true&docId=' } else { var detailsURL = baseURL + '/comicdetails/'; var bookmarkURL = baseURL + '/user-api/bookmark?docId=' } // Fetch and display the read marker for all comics, if we're in a comic screen, // and do nothing otherwise. function updateAllReadStatus(_) { if (!document.getElementById("group")) return; if (document.getElementsByClassName("cell").length == 0) return; let [_str,dirID] = window.location.pathname.match("/(?:comics|books)/([0-9]+)"); let promises = map( document.getElementsByClassName("cell"), cell => { let img = cell.getElementsByTagName("img")[0]; let id = img.src.match("/(?:comics|books)/([0-9]+)/")[1]; return updateReadStatus(cell, id); }); Promise.all(promises).then(statii => { // statuses is an array of booleans, true for finished, false for unfinished let total = statii.length; let read = statii.filter(x => x).length; console.log("Done fetching read status for directory contents, writing bookmark " + dirID + ": " + read + "/" + total); return saveBookmark(dirID, "" + read + "/" + total).then(_ => read == total); }).then(all_read => { // Add a button to refresh the page, for easy use on tablet, since the read- // markers don't always refresh reliably when you get here via "close book". // Do this last so that the refresh button appearing is also a visual indicator for // "all page mongling is complete". let pagelabel = document.getElementById("pagelabel"); pagelabel.innerHTML = (all_read ? '' : '') + '' pagelabel.setAttribute("class", ""); }) } window.markAllRead = function() { let promises = map( document.getElementsByClassName("cell"), cell => { if (cell.is_book) { return saveBookmark(cell.document_id, String(cell.total_pages - 1)); } }); Promise.all(promises).then(_ => updateAllReadStatus()); } window.markAllUnread = function() { let promises = map( document.getElementsByClassName("cell"), cell => { if (cell.is_book) { return saveBookmark(cell.document_id, "-1"); } }); Promise.all(promises).then(_ => updateAllReadStatus()); } function getBubble(cell) { return cell.getElementsByClassName("numberblock")[0].innerText; } // Fetch and display read marker for one comic, identified by cell (the div // containing the thumbnail for that comic) and server-side ID. // Returns a Promise for the fetch->decode->update chain. function updateReadStatus(cell, id) { console.log(sleep); return sleep((id % 50) * 10) .then(_ => fetch(bookmarkURL + id)) .then(response => { if (response.status != 200) { if (!cell.getElementsByTagName("a")[0].onclick) { // Missing bookmark for directory. return {"mark": "0/0"}; } else { return {"mark": "-1"}; } } return response.json(); }).then(json => { if (json.mark.match("/")) { // We've retrieved a folder bookmark previously stored by us; no // need to fetch comic details to get the total page count. let [_,page,total] = json.mark.match("([0-9]+)/([0-9]+)"); return [parseInt(page), parseInt(total), null]; } else { // It's a normal ubooq bookmark stored 0-indexed, we need to fetch the // page count separately. let page = parseInt(json.mark) + 1 return fetch(detailsURL + id).then(r => r.text()).then(text => { let total = parseInt(text.match("nbPages=([0-9]+)")[1]); return [page, total, text]; }) } }).then(args => { let [page,total,details] = args; cell.current_page = page; cell.total_pages = total; cell.document_id = id; if (details) { // It's a book. cell.is_book = true; if (page <= 0) { addBubble(cell, total + " 📕"); // CLOSED BOOK } else if (page < total) { addBubble(cell, "" + page + "/" + total + " 📖"); // OPEN BOOK } else { addBubble(cell, "✓"); } fixupLinks(cell, details); } else { // It's a directory. cell.is_book = false; if (page <= 0) { // We don't know how much stuff is in it -- but we should be able to // tell from the contents of the bubble before we overwrite it. // For now just slap down a "?". if (total <= 0) total = getBubble(cell); addBubble(cell, (total>0? total:"?") + " 📁"); // CLOSED FOLDER } else if (page < total) { addBubble(cell, "" + page + "/" + total + " 📂"); // OPEN FOLDER } else { addBubble(cell, total + " ✓"); //✔ } } return page == total && total > 0; }) } function put(url, body) { return fetch(url, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); } function saveBookmark(id, mark) { return put(bookmarkURL + id, {docId: id, isBook: false, mark: mark, isFinished: false, lastUpdate: 1}); } // Given the thumbnail img for a comic, and the text to put in the bubble, // install a bubble on the thumbnail using the same mechanism as used for the // "number of comics inside this directory" bubble. function addBubble(cell, text) { for (let bubble of cell.getElementsByClassName("numberblock")) { bubble.parentNode.removeChild(bubble); } let div = document.createElement('div'); div.className = "numberblock"; div.innerHTML = '
' + text + '
'; cell.append(div); } // Adjust the linking behaviour of the cell. Make clicking the thumbnail open // the comic without displaying the details popup. Make clicking the comic title // download the comic. function fixupLinks(cell, details) { let a = cell.getElementsByTagName("a")[0]; if (!a.onclick) return; // Doesn't need fixing let reader_url = details.match('/(?:comic|book)reader/reader.html[^"]+')[0]; a.onclick = null; a.href = baseURL + reader_url.replace(/&/g, "&"); let download_url = details.match('href="([^"]*/(?:comics|books)/[0-9]+/[^"]+)"')[1]; let label = cell.getElementsByClassName("label")[0]; label.innerHTML = '' + label.innerText + ''; } // Stuff for the better seek bar. function getScope() { return angular.element(document.querySelector("#pagelabel")).scope(); } // Seek to the given page. Called by the seek slider when it's released. function seekPage(page) { let $scope = getScope(); let oldpage = $scope.currPageNb + 1 if (page == oldpage) return; $scope.currentWay = page > oldpage ? $scope.WAY.FORWARD : $scope.WAY.BACKWARD; $scope.currPageNb = page - 1; $scope.loadPage(false); } // Change the "page X of Y" counter to reflect the position of the slider. // Called when the slider is dragged, and also when our loadPage() wrapper is // called, since the code in ubooq that's meant to keep updating it sometimes // breaks. function updatePageCounter(page) { document.getElementById('pagelink').innerText = 'Page ' + page; document.getElementById('pagemax').innerText = getScope().nbPages; document.getElementById('pagelink').href = baseURL + '/comicreader/' + getScope().documentId + '?page=' + (page-1); // document.getElementById("pagelabel").innerText = // "Page " + page + " of " + getScope().nbPages; } function mkExitShortcutHandler($scope, elem_id, handler, predicate) { let warnCount = 0; return function() { if (!predicate($scope)) return handler(); if (warnCount > 0) { history.back(); return false; } warnCount++; let button = document.getElementById(elem_id); let _class = button.className; button.className = "ubreader-warn"; button.style = "background-color: #f00; opacity: 0.3;"; setTimeout(_ => { warnCount--; button.style = ""; if (button.className == "ubreader-warn") button.className = _class; }, 1000); return handler(); } } // Initial setup for the improved page seek bar. function installPageSeekBar(_) { let bar = document.getElementById("progressbar"); if (!bar) return; // Not currently reading a book. // Adjust the lower margin so that the Chrome status bar doesn't cover actual // content. // document.getElementById("contentCanvas").style = "padding: 0 0 2em 0;"; // Install the upgraded page number indicator. document.getElementById("pagelabel").innerHTML = 'Page [loading] of [loading]'; // Install a wrapper around $scope.loadPage() that properly updates the // page counter and seek bar. This is called every time a new page is // loaded, so it should keep things in sync... let $scope = getScope(); let _loadPage = $scope.loadPage; $scope.loadPage = function(firstCall) { document.getElementById("pageseekbar").value = $scope.currPageNb + 1; updatePageCounter($scope.currPageNb + 1); return _loadPage(firstCall); } // Override the next/previous page buttons to flash a warning on the first tap // and close the book on the second if at the start/end of the book. $scope.nextPage = mkExitShortcutHandler( $scope, "rightmenu", $scope.nextPage, $scope => { return $scope.currPageNb+1 == $scope.nbPages; }); $scope.previousPage = mkExitShortcutHandler( $scope, "leftmenu", $scope.previousPage, $scope => { return $scope.currPageNb == 0; }); // Replace the progress bar with a range input that the user can drag in // order to easily select a page. let val = $scope.currPageNb + 1; let max = $scope.nbPages; bar.innerHTML = ''; // Delete the empty 'href' attributes from the hotspots on the read page // so that FF/chrome don't helpfully display a URL bar and cut off part // of the comic. document.getElementById('leftbar').removeAttribute('href'); document.getElementById('centerbar').removeAttribute('href'); document.getElementById('rightbar').removeAttribute('href'); } function installPageLinkButton(_) { let gotobutton = document.getElementById('gotobutton'); let pagelinkbutton = htmlToElement( ''); gotobutton.parent.insertAfter } // Add "resume last comic" functionality. // If on the top-level screen (that has "comics" and "new comics"), rewires // "new comics" to be a "read now" button that takes you to the last folder // you were reading something in instead. (We can't go straight to the comic // because it relies on browser history for "close book" to take you from the // comic back to the folder list, and if we just jump straight to the book the // history isn't there; TODO: fix this, probably using history.pushState.) function enableResumeSupport(kind) { if (!document.getElementById("group")) return; console.log("enable resume support for " + kind); let latest = document.getElementById("latest-" + kind); let resume = localStorage.getItem('ubreader:resume:' + kind) || "/"+kind+"/"; if (latest) { latest.style.backgroundImage = 'url("' + baseURL + '/theme/read.png")'; latest.style.height = "100%"; latest.href = baseURL + resume; latest.innerText = 'Resume Last'; return; } let match = window.location.pathname.match("/"+kind+"/([0-9]+)"); if (match) localStorage.setItem('ubreader:resume:'+kind, '/'+kind+'/'+match[1]); } function getUser() { let info = document.getElementById('userinfo'); if (!info) return localStorage.getItem('ubreader:user'); let user = info.innerText.match('Connected as (.*) - Log out')[1]; localStorage.setItem('ubreader:user', user); return user; } // TODO -- this doesn't actually set the taskbar icon when using chromium. // Investigate using Konquerer instead, which reportedly supports this. function setFavicon(_) { var link; while (link = document.querySelector("link[rel*='icon']")) { link.parentNode.removeChild(link); } link = document.createElement('link'); link.type = 'image/x-icon'; link.rel = 'shortcut icon'; link.href = '/u/' + getUser() + '.png'; document.getElementsByTagName('head')[0].appendChild(link); document.title = getUser() + " - " + document.title; } function htmlToElement(html) { var template = document.createElement('template'); template.innerHTML = html; return template.content.firstChild; } function installBookSortOptions() { // TODO: if the BookSettings cookie looks like "...#path#...", make this checked console.log("Installing book sort by path option"); let path_sort = htmlToElement( ''); let author_sort = document.querySelector('input[value="authors"]').parentNode; author_sort.parentNode.insertBefore(path_sort, author_sort); } // It sometimes takes a few hundred millis after closing a book for the read // status to update on the server, so we delay briefly before loading // the read status. window.addEventListener('load', _ => { setTimeout(updateAllReadStatus, 1000); }); window.addEventListener('load', installPageSeekBar); window.addEventListener('load', _ => { enableResumeSupport('comics'); enableResumeSupport('books'); }); window.addEventListener('load', setFavicon) if (isBook) { window.addEventListener('load', installBookSortOptions); } // TODO: add support for "fit largest" in addition to fit height/width/original