// ==UserScript==
// @name Kobo e-Books Update Checker
// @name:zh-TW Kobo 電子書更新檢查器
// @description Checks if updates were available for the e-books you own.
// @description:zh-TW 檢查你購買的電子書是否有更新檔提供。
// @icon https://icons.duckduckgo.com/ip3/www.kobo.com.ico
// @author Jason Kwok
// @namespace https://jasonhk.dev/
// @version 1.7.2
// @license MIT
// @match https://www.kobo.com/*/*/library/books
// @match https://www.kobo.com/*/*/library/books?*
// @match https://www.kobo.com/*/*/library/Books
// @match https://www.kobo.com/*/*/library/Books?*
// @match https://www.kobo.com/*/*/library/archive
// @match https://www.kobo.com/*/*/library/archive?*
// @run-at document-end
// @grant GM.setClipboard
// @require https://update.greasyfork.org/scripts/483122/1303118/style-shims.js
// @require https://unpkg.com/[email protected]/dist/i18n.object.min.js
// @require https://update.greasyfork.org/scripts/482311/1297431/queue.js
// @supportURL https://greasyfork.org/scripts/482410/feedback
// ==/UserScript==
const LL = (function()
{
const translations =
{
en: {
HEADER: {
MESSAGE: "Message",
},
BUTTON: {
OKAY: "Okay",
CHECK_PAGE: "Check Update for Page",
CHECKING_PAGE: "Checking Update...",
CHECK_SINGLE: "Check Update",
COPY_OUTDATED: "Copy Outdated Books",
},
STATUS: {
PENDING: "Pending...",
CHECKING: "Checking...",
LATEST: "Latest",
OUTDATED: "Outdated",
PREVIEW: "Preview",
SKIPPED: "Skipped",
FAILED: "Failed",
},
ERROR: {
UNLISTED: "This book was unlisted, there’s no way to check update for this type of books at the moment.",
PARSING: "Failed to parse the latest product ID, please contact the developer for further investigations.",
UNKNOWN: "Unknown error, please contact the developer for further investigations.",
},
MESSAGE: {
FINISHED_CHECKING_PAGE: "Finished checking all books for this page.\n\nLatest: {latest}\nOutdated: {outdated}\nSkipped: {skipped}\nFailed: {failed}",
NO_BOOKS_BEEN_CHECKED: "No books have been checked.",
NO_BOOKS_WERE_OUTDATED: "No books were outdated.",
COPIED_BOOKS: "Copied {0} book{{s}} into the clipboard.",
},
},
zh: {
HEADER: {
MESSAGE: "訊息",
},
BUTTON: {
OKAY: "確定",
CHECK_PAGE: "為本頁檢查更新",
CHECKING_PAGE: "正在檢查更新…",
CHECK_SINGLE: "檢查更新",
COPY_OUTDATED: "複製過時書籍",
},
STATUS: {
PENDING: "等待中…",
CHECKING: "檢查中…",
LATEST: "最新",
OUTDATED: "過時",
PREVIEW: "預覽",
SKIPPED: "已略過",
FAILED: "檢查失敗",
},
ERROR: {
UNLISTED: "該書已下架,目前尚未有方法為這類書籍檢查更新。",
PARSING: "無法解析最新的產品編號,請聯絡開發者以進一步調查。",
UNKNOWN: "未知錯誤,請聯絡開發者以進一步調查。",
},
MESSAGE: {
FINISHED_CHECKING_PAGE: "完成檢查本頁的書籍。\n\n最新:{latest}\n過時:{outdated}\n已略過:{skipped}\n檢查失敗:{failed}",
NO_BOOKS_BEEN_CHECKED: "沒有已檢查的書籍。",
NO_BOOKS_WERE_OUTDATED: "沒有過時的書籍。",
COPIED_BOOKS: "已複製 {0} 本書到剪貼簿。",
},
},
};
let locale = location.pathname.match(/\/[a-z]{2}\/([a-z]{2})\//)?.[1] ?? "en";
if (!Object.keys(translations).includes(locale))
{
console.warn("No translations available for this locale.")
locale = "en";
}
return i18nObject(locale, translations[locale]);
})();
GM.addStyle(`
.library-container .update-container
{
text-align: right;
}
.library-container .update-controls
{
position: relative;
display: inline-block;
margin-top: 18px;
min-width: 13rem;
width: auto;
}
.library-container .update-button
{
border-radius: 20px;
min-width: 0;
max-width: 100%;
width: auto;
overflow: hidden;
background-color: #eee;
color: #000;
font-size: 1.6rem;
font-family: "Rakuten Sans UI", "Trebuchet MS", Trebuchet, Arial, Helvetica, sans-serif;
font-weight: 400;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
position: relative;
white-space: nowrap;
transition: background-color .3s ease-in-out, color .15s ease-in-out 0s;
}
.library-container .update-button:not(:first-child)
{
margin-left: 5px;
}
.library-container .update-button:not(:last-child)
{
margin-right: 5px;
}
.library-container .update-button::before
{
position: absolute;
top: calc(50% - 30px);
border-radius: 80px;
width: calc(100% - 30px);
height: 60px;
background-color: rgba(0, 0, 0, .1);
content: "";
opacity: 0;
transform: scale(0);
}
.library-container .update-button:disabled
{
background-color: "#dcdcdc";
color: rgba(0,0,0,.42);
pointer-events: none;
}
.library-container .update-button:hover
{
background-color: rgba(0, 0, 0, .04);
}
.library-container .update-button:focus::before
{
opacity: 1;
transform: scale(1);
}
.library-container .update-button:active
{
background-color: #000;
color: #fff;
}
@media (max-width: 568px)
{
.library-container .secondary-controls
{
margin-right: 18px;
}
.library-container .update-container
{
text-align: left;
}
.library-container .update-controls
{
margin-right: 0;
width: 100%;
white-space: break-spaces;
}
.library-container .update-button
{
margin-left: 0 !important;
margin-right: 0 !important;
width: 100%;
text-align: center;
}
.library-container .update-button:not(:first-child)
{
margin-top: 18px;
}
.library-container .library-content.grid .more-actions:not(.open)
{
width: fit-content;
transform: translateY(35px);
}
}
.item-wrapper.book[data-check-status=outdated] .product-field.item-status
{
background: #FE8484;
}
.item-wrapper.book[data-check-status=skipped] .product-field.item-status
{
background: #B5B5B5;
}
.item-wrapper.book[data-check-status=failed] .product-field.item-status
{
background: #FFA700;
}
.item-wrapper.book:is([data-check-status=skipped], [data-check-status=failed]) .product-field.item-status a
{
text-decoration-line: underline;
cursor: help;
}
`);
function showModal(message)
{
const modal = document.createElement("div");
modal.id = "modal";
modal.addEventListener("click", (event) => ((event.target === modal) && closeModal()));
const modalContent = document.createElement("div");
modalContent.id = "modal-content";
modalContent.classList.add("library-modal");
const modalContainer = document.createElement("div");
const closeWrapper = document.createElement("div");
closeWrapper.classList.add("wrapper");
const closeButton = document.createElement("button");
closeButton.classList.add("modal-x", "close");
closeButton.addEventListener("click", closeModal);
const actionWrapper = document.createElement("div");
actionWrapper.classList.add("wrapper");
const actionContainer = document.createElement("div");
actionContainer.classList.add("action-container");
const actionHeader = document.createElement("h2");
actionHeader.classList.add("confirm");
actionHeader.textContent = LL.HEADER.MESSAGE();
const actionMessage = document.createElement("p");
actionMessage.style = "display: inline-block; text-align: left;";
actionMessage.innerHTML = String(message).replaceAll("\n", "<br />");
const actionButtons = document.createElement("div");
actionButtons.classList.add("cta");
actionButtons.style = "display: block;";
const okayButton = document.createElement("button");
okayButton.classList.add("primary-button", "okay");
okayButton.textContent = LL.BUTTON.OKAY();
okayButton.addEventListener("click", closeModal);
closeWrapper.append(closeButton);
actionButtons.append(okayButton);
actionContainer.append(actionHeader, actionMessage, actionButtons);
actionWrapper.append(actionContainer);
modalContainer.append(closeWrapper, actionWrapper);
modalContent.append(modalContainer);
modal.append(modalContent);
document.body.append(modal);
document.body.classList.add("show-modal");
function closeModal()
{
if (document.querySelectorAll("#modal").length > 1)
{
modal.remove()
}
else
{
document.body.classList.remove("show-modal");
setTimeout(() => modal.remove(), 250);
}
}
}
const Status =
{
PENDING: "pending",
CHECKING: "checking",
LATEST: "latest",
OUTDATED: "outdated",
SKIPPED: "skipped",
FAILED: "failed",
};
const queue = new Queue({ autostart: true, concurrency: 6 });
queue.addEventListener("error", (event) =>
{
console.error(event.detail.error);
showModal(LL.ERROR.UNKNOWN());
});
const books = Array.from(document.querySelectorAll(".item-wrapper.book"));
for (const book of books)
{
const actions = book.querySelector(".item-info + .item-bar .library-actions-list");
const actionContainer = document.createElement("li");
actionContainer.classList.add("library-actions-list-item");
const action = document.createElement("button");
action.classList.add("library-action");
action.textContent = LL.BUTTON.CHECK_SINGLE();
action.addEventListener("click", () => checkUpdate(book));
actionContainer.appendChild(action);
actions.appendChild(actionContainer);
}
const secondaryControls = document.querySelector(".secondary-controls");
const updateContainer = document.createElement("div");
updateContainer.classList.add("update-container");
const updateControls = document.createElement("div");
updateControls.classList.add("update-controls");
const checkButton = document.createElement("button");
checkButton.classList.add("update-button");
checkButton.textContent = LL.BUTTON.CHECK_PAGE();
checkButton.addEventListener("click", () => checkUpdateForBooks(books));
const copyButton = document.createElement("button");
copyButton.classList.add("update-button");
copyButton.textContent = LL.BUTTON.COPY_OUTDATED();
copyButton.addEventListener("click", () =>
{
if (!books.some((book) => book.dataset.checkStatus))
{
showModal(LL.MESSAGE.NO_BOOKS_BEEN_CHECKED());
return;
}
const outdated = books.filter((book) => (book.dataset.checkStatus === Status.OUTDATED));
if (outdated.length > 0)
{
GM.setClipboard(outdated.map(getBookTitle).join("\n"));
showModal(LL.MESSAGE.COPIED_BOOKS(outdated.length));
}
else
{
showModal(LL.MESSAGE.NO_BOOKS_WERE_OUTDATED());
}
});
updateControls.append(checkButton, copyButton);
updateContainer.appendChild(updateControls);
secondaryControls.insertBefore(updateContainer, secondaryControls.firstChild);
function getBookTitle(book)
{
return book.querySelector(".product-field.title").innerText;
}
function getCurrentProductId(book)
{
const config = JSON.parse(book.querySelector(".library-action:is(.mark-as-finished, .remove-from-archive)").dataset.koboGizmoConfig);
return config.productId;
}
async function getLatestProductId(book)
{
const response = await fetch(book.querySelector(".product-field.title a").href, { credentials: "omit" });
if (!response.ok)
{
if (response.status === 404)
{
throw new Error(LL.ERROR.UNLISTED());
}
throw new Error(LL.ERROR.UNKNOWN());
}
const html = await response.text();
const parser = new DOMParser();
const page = parser.parseFromString(html, "text/html");
const itemId = page.querySelector("#ratItemId");
if (itemId) { return itemId.value; }
const config = page.querySelector(".item-detail");
if (config) { return JSON.parse(config.dataset.koboGizmoConfig).productId; }
throw new Error(LL.ERROR.PARSING());
}
function checkUpdate(book)
{
const message = book.querySelector(".product-field.item-status");
book.dataset.checkStatus = Status.PENDING;
message.replaceChildren(LL.STATUS.PENDING());
queue.push(async () =>
{
book.dataset.checkStatus = Status.CHECKING;
message.textContent = LL.STATUS.CHECKING();
if (book.dataset.koboGizmo === "PreviewLibraryItem")
{
book.dataset.checkStatus = Status.SKIPPED;
message.classList.remove("buy-now");
message.replaceChildren(LL.STATUS.PREVIEW());
return;
}
try
{
const currentId = getCurrentProductId(book);
const latestId = await getLatestProductId(book);
console.debug(`${getBookTitle(book)}\n Current: ${currentId}\n Latest : ${latestId}`);
if (currentId === latestId)
{
book.dataset.checkStatus = Status.LATEST;
message.replaceChildren(LL.STATUS.LATEST());
}
else
{
book.dataset.checkStatus = Status.OUTDATED;
message.replaceChildren(LL.STATUS.OUTDATED());
}
}
catch (e)
{
book.dataset.checkStatus = Status.FAILED;
const link = document.createElement("a");
link.textContent = LL.STATUS.FAILED();
link.addEventListener("click", (event) => showModal(e.message));
message.replaceChildren(link);
// throw e;
}
});
}
function checkUpdateForBooks(books)
{
checkButton.disabled = true;
checkButton.textContent = LL.BUTTON.CHECKING_PAGE();
queue.addEventListener("end", () =>
{
let latest = 0;
let outdated = 0;
let skipped = 0;
let failed = 0;
for (const book of books)
{
switch (book.dataset.checkStatus)
{
case Status.LATEST:
latest++;
break;
case Status.OUTDATED:
outdated++;
break;
case Status.SKIPPED:
skipped++
break;
case Status.FAILED:
failed++;
break;
}
}
showModal(LL.MESSAGE.FINISHED_CHECKING_PAGE({ latest, outdated, skipped, failed }));
checkButton.disabled = false;
checkButton.textContent = LL.BUTTON.CHECK_PAGE();
}, { once: true });
books.forEach(checkUpdate);
}