mirror of
https://github.com/koodo-reader/koodo-reader.git
synced 2026-06-11 09:24:48 -04:00
1623 lines
47 KiB
TypeScript
1623 lines
47 KiB
TypeScript
import Plugin from "../models/Plugin";
|
||
import { isElectron } from "react-device-detect";
|
||
import CryptoJS from "crypto-js";
|
||
import {
|
||
CommonTool,
|
||
ConfigService,
|
||
KookitConfig,
|
||
SyncUtil,
|
||
TokenService,
|
||
} from "../assets/lib/kookit-extra-browser.min";
|
||
import Book from "../models/Book";
|
||
import BookUtil from "./file/bookUtil";
|
||
import * as Kookit from "../assets/lib/kookit.min";
|
||
import DatabaseService from "./storage/databaseService";
|
||
import packageJson from "../../package.json";
|
||
import toast from "react-hot-toast";
|
||
import i18n from "../i18n";
|
||
import {
|
||
encryptToken,
|
||
getCloudSyncToken,
|
||
refreshThirdToken,
|
||
} from "./request/thirdparty";
|
||
import {
|
||
getCloudConfig,
|
||
getCloudToken,
|
||
removeCloudConfig,
|
||
} from "./file/common";
|
||
import SyncService from "./storage/syncService";
|
||
import localforage from "localforage";
|
||
import { driveList } from "../constants/driveList";
|
||
import { updateUserConfig } from "./request/user";
|
||
import { languageCNMap, languageENMap } from "../constants/ttsList";
|
||
import { BookHelper } from "../assets/lib/kookit.min";
|
||
declare var window: any;
|
||
export const supportedFormats = [
|
||
".epub",
|
||
".pdf",
|
||
".txt",
|
||
".mobi",
|
||
".azw3",
|
||
".azw",
|
||
".htm",
|
||
".html",
|
||
".xml",
|
||
".xhtml",
|
||
".mhtml",
|
||
".docx",
|
||
".md",
|
||
".fb2",
|
||
".cbz",
|
||
".cbt",
|
||
".cbr",
|
||
".cb7",
|
||
];
|
||
export const calculateFileMD5 = (file: File): Promise<string> => {
|
||
return new Promise((resolve, reject) => {
|
||
if (isElectron) {
|
||
const reader = new FileReader();
|
||
reader.onload = (event) => {
|
||
const arrayBuffer = event.target?.result as ArrayBuffer;
|
||
const crypto = window.require("crypto");
|
||
const hash = crypto.createHash("md5");
|
||
hash.update(Buffer.from(arrayBuffer));
|
||
resolve(hash.digest("hex"));
|
||
};
|
||
reader.onerror = (error) => reject(error);
|
||
reader.readAsArrayBuffer(file);
|
||
} else {
|
||
const reader = new FileReader();
|
||
reader.onload = (event) => {
|
||
const arrayBuffer = event.target?.result as ArrayBuffer;
|
||
const wordArray = CryptoJS.lib.WordArray.create(
|
||
new Uint8Array(arrayBuffer) as any
|
||
);
|
||
resolve(CryptoJS.MD5(wordArray).toString());
|
||
};
|
||
reader.onerror = (error) => reject(error);
|
||
reader.readAsArrayBuffer(file);
|
||
}
|
||
});
|
||
};
|
||
export const vexPromptAsync = (message, placeholder = "", value = "") => {
|
||
return new Promise((resolve) => {
|
||
window.vex.dialog.buttons.YES.text = i18n.t("Confirm");
|
||
window.vex.dialog.buttons.NO.text = i18n.t("Cancel");
|
||
window.vex.dialog.prompt({
|
||
message,
|
||
placeholder,
|
||
value,
|
||
callback: function (input) {
|
||
resolve(input);
|
||
},
|
||
});
|
||
});
|
||
};
|
||
export const vexTextareaAsync = (message, value = "") => {
|
||
return new Promise<string | false>((resolve) => {
|
||
window.vex.dialog.buttons.YES.text = i18n.t("Confirm");
|
||
window.vex.dialog.buttons.NO.text = i18n.t("Cancel");
|
||
const textareaHtml = [
|
||
`<div style="margin-bottom:10px">`,
|
||
`<textarea name="vex-textarea" style="width:100%;height:200px;">${value}</textarea>`,
|
||
`</div>`,
|
||
].join("");
|
||
window.vex.dialog.open({
|
||
unsafeMessage: message ? i18n.t(message).replace(/\n/g, "<br>") : "",
|
||
input: textareaHtml,
|
||
callback: function (data) {
|
||
if (!data) {
|
||
resolve(false);
|
||
} else {
|
||
resolve(data["vex-textarea"] ?? "");
|
||
}
|
||
},
|
||
});
|
||
});
|
||
};
|
||
|
||
export const vexComfirmAsync = (
|
||
message: string,
|
||
confirmText: string = "Confirm",
|
||
cancelText: string = "Cancel"
|
||
) => {
|
||
return new Promise((resolve) => {
|
||
window.vex.dialog.buttons.YES.text = i18n.t(confirmText);
|
||
window.vex.dialog.buttons.NO.text = i18n.t(cancelText);
|
||
window.vex.dialog.confirm({
|
||
unsafeMessage: i18n.t(message),
|
||
contentClassName: "custom-confirm-width",
|
||
callback: (value) => {
|
||
if (value) {
|
||
resolve(true);
|
||
} else {
|
||
resolve(false);
|
||
}
|
||
},
|
||
});
|
||
});
|
||
};
|
||
export const vexOpenAsync = (
|
||
config: Record<string, any>,
|
||
message: string,
|
||
labels?: Record<string, string>,
|
||
tutorialUrl?: string
|
||
) => {
|
||
return new Promise<Record<string, any> | false>((resolve) => {
|
||
window.vex.dialog.buttons.YES.text = i18n.t("Confirm");
|
||
window.vex.dialog.buttons.NO.text = i18n.t("Cancel");
|
||
const escapeAttr = (value: string) =>
|
||
value
|
||
.replace(/&/g, "&")
|
||
.replace(/"/g, """)
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">");
|
||
const keys = Object.keys(config).filter((k) => k && k.trim());
|
||
const inputHtml = keys
|
||
.map((key) => {
|
||
const raw = config[key] ?? "";
|
||
let placeholder = "";
|
||
let value = "";
|
||
let inputType = "text";
|
||
if (typeof raw === "string") {
|
||
placeholder = raw.indexOf("[") > -1 ? raw : "";
|
||
value = raw.indexOf("[") === -1 ? raw : "";
|
||
} else if (raw && typeof raw === "object") {
|
||
placeholder = raw.placeholder || "";
|
||
value = raw.value || "";
|
||
inputType = raw.type || "text";
|
||
}
|
||
const displayLabel = labels?.[key] ?? key;
|
||
return [
|
||
`<div style="margin-bottom:10px">`,
|
||
`<label style="display:block;margin-bottom:4px;font-weight:500">${displayLabel}</label>`,
|
||
`<input name="${key}" type="${escapeAttr(
|
||
inputType
|
||
)}" placeholder="${escapeAttr(placeholder)}" value="${escapeAttr(
|
||
value
|
||
)}" style="width:100%" required />`,
|
||
`</div>`,
|
||
].join("");
|
||
})
|
||
.join("");
|
||
const buttons = [
|
||
window.vex.dialog.buttons.YES,
|
||
window.vex.dialog.buttons.NO,
|
||
...(tutorialUrl
|
||
? [
|
||
{
|
||
text: i18n.t("Tutorial"),
|
||
type: "button",
|
||
className: "vex-dialog-button-secondary",
|
||
click: function () {
|
||
openExternalUrl(tutorialUrl!);
|
||
},
|
||
},
|
||
]
|
||
: []),
|
||
];
|
||
window.vex.dialog.open({
|
||
unsafeMessage: message ? i18n.t(message).replace(/\n/g, "<br>") : "",
|
||
input: inputHtml,
|
||
buttons,
|
||
callback: function (data) {
|
||
if (!data) {
|
||
resolve(false);
|
||
} else {
|
||
const result: Record<string, any> = {};
|
||
for (const key of keys) {
|
||
result[key] = data[key] ?? "";
|
||
}
|
||
resolve(result);
|
||
}
|
||
},
|
||
});
|
||
});
|
||
};
|
||
|
||
export const vexPasswordInputAsync = (
|
||
message: string,
|
||
confirmMessage?: string,
|
||
isNoOverlay?: boolean
|
||
) => {
|
||
return new Promise<string | false>((resolve) => {
|
||
window.vex.dialog.buttons.YES.text = i18n.t("Confirm");
|
||
window.vex.dialog.buttons.NO.text = i18n.t("Cancel");
|
||
const inputHtml = confirmMessage
|
||
? [
|
||
`<div style="margin-bottom:10px">`,
|
||
`<label style="display:block;margin-bottom:4px;font-weight:500">${i18n.t(message)}</label>`,
|
||
`<input name="vex-pwd" type="password" style="width:100%" required />`,
|
||
`</div>`,
|
||
`<div style="margin-bottom:10px">`,
|
||
`<label style="display:block;margin-bottom:4px;font-weight:500">${i18n.t(confirmMessage)}</label>`,
|
||
`<input name="vex-pwd-confirm" type="password" style="width:100%" required />`,
|
||
`</div>`,
|
||
].join("")
|
||
: [
|
||
`<div style="margin-bottom:10px">`,
|
||
`<label style="display:block;margin-bottom:4px;font-weight:500">${i18n.t(message)}</label>`,
|
||
`<input name="vex-pwd" type="password" style="width:100%" required />`,
|
||
`</div>`,
|
||
].join("");
|
||
window.vex.dialog.open({
|
||
input: inputHtml,
|
||
overlayClassName: isNoOverlay ? "no-overlay" : "",
|
||
callback: function (data) {
|
||
if (!data) {
|
||
resolve(false);
|
||
return;
|
||
}
|
||
const pwd: string = data["vex-pwd"] ?? "";
|
||
if (!pwd) {
|
||
resolve(false);
|
||
return;
|
||
}
|
||
if (confirmMessage) {
|
||
const confirm: string = data["vex-pwd-confirm"] ?? "";
|
||
if (pwd !== confirm) {
|
||
resolve(false);
|
||
return;
|
||
}
|
||
}
|
||
resolve(pwd);
|
||
},
|
||
});
|
||
});
|
||
};
|
||
|
||
export const vexSelectAsync = (
|
||
message: string,
|
||
options: { value: string; label: string }[]
|
||
) => {
|
||
return new Promise<string | false>((resolve) => {
|
||
window.vex.dialog.buttons.YES.text = i18n.t("Confirm");
|
||
window.vex.dialog.buttons.NO.text = i18n.t("Cancel");
|
||
const optionsHtml = options
|
||
.map((o) => `<option value="${o.value}">${i18n.t(o.label)}</option>`)
|
||
.join("");
|
||
const selectHtml = `<select name="vex-select" style="width:100%;padding:6px">${optionsHtml}</select>`;
|
||
window.vex.dialog.open({
|
||
unsafeMessage: i18n.t(message),
|
||
input: selectHtml,
|
||
callback: function (data) {
|
||
if (!data) {
|
||
resolve(false);
|
||
} else {
|
||
resolve(data["vex-select"] ?? false);
|
||
}
|
||
},
|
||
});
|
||
});
|
||
};
|
||
|
||
export const getFormatFromAudioPath = (audioPath: string) => {
|
||
let format = "mp3";
|
||
if (audioPath.indexOf(".wav") > -1) {
|
||
format = "wav";
|
||
} else if (audioPath.indexOf(".ogg") > -1) {
|
||
format = "ogg";
|
||
} else if (audioPath.indexOf(".flac") > -1) {
|
||
format = "flac";
|
||
} else if (audioPath.indexOf(".aac") > -1) {
|
||
format = "aac";
|
||
}
|
||
return format;
|
||
};
|
||
export const fetchFileFromPath = (filePath: string) => {
|
||
return new Promise<File>((resolve) => {
|
||
const fs = window.require("fs");
|
||
|
||
fs.readFile(filePath, (err, data) => {
|
||
if (err) {
|
||
console.error(err);
|
||
return;
|
||
}
|
||
const file = new File(
|
||
[data],
|
||
window.navigator.platform.indexOf("Win") > -1
|
||
? filePath.split("\\").reverse()[0]
|
||
: filePath.split("/").reverse()[0],
|
||
{
|
||
lastModified: new Date().getTime(),
|
||
}
|
||
);
|
||
resolve(file);
|
||
});
|
||
});
|
||
};
|
||
|
||
export const sleep = (time: number) => {
|
||
return new Promise((resolve) => setTimeout(resolve, time));
|
||
};
|
||
|
||
export const scrollContents = (chapterTitle: string, chapterHref: string) => {
|
||
let contentBody = document.getElementsByClassName("navigation-body")[0];
|
||
if (!contentBody) return;
|
||
let contentList = contentBody.getElementsByClassName("book-content-name");
|
||
let targetContent = Array.from(contentList).filter((item) => {
|
||
item.setAttribute("style", "");
|
||
let dataHref = (item as any).getAttribute("data-href");
|
||
if (chapterHref) {
|
||
return item.textContent === chapterTitle && dataHref === chapterHref;
|
||
} else {
|
||
return item.textContent === chapterTitle;
|
||
}
|
||
});
|
||
if (targetContent.length > 0) {
|
||
contentBody.scrollTo({
|
||
left: 0,
|
||
top: (targetContent[0] as any).offsetTop,
|
||
behavior: "smooth",
|
||
});
|
||
targetContent[0].setAttribute("style", "color:red; font-weight: bold");
|
||
}
|
||
};
|
||
export const handleFullScreen = () => {
|
||
if (isElectron) {
|
||
if (ConfigService.getReaderConfig("isOpenInMain") === "yes") {
|
||
window
|
||
.require("electron")
|
||
.ipcRenderer.invoke("enter-tab-fullscreen", "ping");
|
||
} else {
|
||
window.require("electron").ipcRenderer.invoke("enter-fullscreen", "ping");
|
||
}
|
||
}
|
||
};
|
||
export const handleExitFullScreen = () => {
|
||
if (isElectron) {
|
||
if (ConfigService.getReaderConfig("isOpenInMain") === "yes") {
|
||
window
|
||
.require("electron")
|
||
.ipcRenderer.invoke("exit-tab-fullscreen", "ping");
|
||
} else {
|
||
window.require("electron").ipcRenderer.invoke("exit-fullscreen", "ping");
|
||
}
|
||
}
|
||
};
|
||
export const getQueryParams = (url: string) => {
|
||
const urlObj = new URL(url);
|
||
const params = new URLSearchParams(urlObj.search);
|
||
const queryParams = {};
|
||
for (let pair of params.entries()) {
|
||
queryParams[pair[0]] = pair[1];
|
||
}
|
||
return queryParams;
|
||
};
|
||
export const getStorageLocation = () => {
|
||
if (isElectron) {
|
||
return ConfigService.getItem("storageLocation")
|
||
? ConfigService.getItem("storageLocation")
|
||
: window
|
||
.require("electron")
|
||
.ipcRenderer.sendSync("storage-location", "ping");
|
||
} else {
|
||
return ConfigService.getItem("storageLocation");
|
||
}
|
||
};
|
||
export const getAllVoices = (pluginList: Plugin[]) => {
|
||
let voiceList: any[] = [];
|
||
for (
|
||
let index = 0;
|
||
index < pluginList.filter((item) => item.type === "voice").length;
|
||
index++
|
||
) {
|
||
const plugin = pluginList.filter((item) => item.type === "voice")[index];
|
||
voiceList.push(...(plugin.voiceList as any[]));
|
||
}
|
||
return voiceList;
|
||
};
|
||
export const checkPlugin = async (plugin: Plugin) => {
|
||
if (
|
||
(await CommonTool.generateSHA256Hash(plugin.script)) !== plugin.scriptSHA256
|
||
) {
|
||
return false;
|
||
} else {
|
||
return true;
|
||
}
|
||
};
|
||
export const reloadManager = () => {
|
||
if (isElectron) {
|
||
window.require("electron").ipcRenderer.invoke("reload-main", "ping");
|
||
} else {
|
||
window.location.reload();
|
||
}
|
||
};
|
||
export const openExternalUrl = (
|
||
url: string,
|
||
isPlugin: boolean = false,
|
||
type: string = "link"
|
||
) => {
|
||
isElectron
|
||
? ConfigService.getReaderConfig("isUseBuiltIn") === "yes" || isPlugin
|
||
? window.require("electron").ipcRenderer.invoke("open-url", { url, type })
|
||
: window.require("electron").shell.openExternal(url)
|
||
: window.open(url);
|
||
};
|
||
export const openInBrowser = (url: string) => {
|
||
isElectron
|
||
? window.require("electron").shell.openExternal(url)
|
||
: window.open(url);
|
||
};
|
||
export const getPageWidth = (
|
||
readerMode: string,
|
||
scale: string,
|
||
margin: number,
|
||
isNavLocked: boolean,
|
||
isSettingLocked: boolean
|
||
) => {
|
||
if (Math.abs(parseFloat(scale)) > 1.4) {
|
||
scale = "1.3";
|
||
}
|
||
const findValidMultiple = (limit: number) => {
|
||
let multiple = limit - (limit % 12);
|
||
|
||
while (multiple >= 0) {
|
||
if (((multiple - multiple / 12) / 2) % 2 === 0) {
|
||
return multiple;
|
||
}
|
||
multiple -= 12;
|
||
}
|
||
|
||
return limit;
|
||
};
|
||
if (
|
||
document.body.clientWidth * Math.abs(parseFloat(scale)) -
|
||
document.body.clientWidth * 0.4 >
|
||
document.body.clientWidth
|
||
) {
|
||
let pageWidth = document.body.clientWidth - 106;
|
||
let pageOffset = 50 + "px";
|
||
return {
|
||
pageOffset,
|
||
pageWidth: pageWidth + "px",
|
||
};
|
||
}
|
||
|
||
let pageOffset = "";
|
||
let pageWidth = 0;
|
||
if (readerMode === "scroll" || readerMode === "single") {
|
||
let preWidth =
|
||
document.body.clientWidth * Math.abs(parseFloat(scale)) -
|
||
document.body.clientWidth * 0.4 -
|
||
(isNavLocked ? 300 : 0) -
|
||
(isSettingLocked ? 300 : 0);
|
||
let width = findValidMultiple(preWidth);
|
||
pageOffset = `calc(50vw + ${isNavLocked ? 150 : 0}px - ${
|
||
isSettingLocked ? 150 : 0
|
||
}px - ${width / 2}px)`;
|
||
pageWidth = width;
|
||
} else if (readerMode === "double") {
|
||
let width = findValidMultiple(
|
||
document.body.clientWidth -
|
||
2 * margin -
|
||
80 -
|
||
(isNavLocked ? 300 : 0) -
|
||
(isSettingLocked ? 300 : 0)
|
||
);
|
||
pageOffset = `calc(50vw + ${isNavLocked ? 150 : 0}px - ${
|
||
isSettingLocked ? 150 : 0
|
||
}px - ${width / 2}px)`;
|
||
pageWidth = width;
|
||
}
|
||
if (pageWidth > document.body.clientWidth) {
|
||
pageWidth = document.body.clientWidth - 106;
|
||
pageOffset = 50 + "px";
|
||
}
|
||
return {
|
||
pageOffset,
|
||
pageWidth: pageWidth + "px",
|
||
};
|
||
};
|
||
export const loadFontData = async () => {
|
||
try {
|
||
if (!window.queryLocalFonts) return [];
|
||
const availableFonts = await window.queryLocalFonts();
|
||
return availableFonts.map((font: any) => {
|
||
return {
|
||
label: font.fullName,
|
||
value: `"${font.fullName}", "${font.postscriptName}", "${font.family}"`,
|
||
};
|
||
});
|
||
} catch (error) {
|
||
console.error(error);
|
||
}
|
||
};
|
||
|
||
export function removeSearchParams() {
|
||
const url = new URL(window.location.href.split("?")[0]);
|
||
window.history.replaceState({}, document.title, url.toString());
|
||
}
|
||
export const getChatLocale = () => {
|
||
if (navigator.language.startsWith("zh")) {
|
||
return "zh_CN";
|
||
} else {
|
||
return "en";
|
||
}
|
||
};
|
||
export function addChatBox() {
|
||
const scriptContent = `
|
||
(function (d, t) {
|
||
var BASE_URL = "https://app.chatwoot.com";
|
||
var g = d.createElement(t),
|
||
s = d.getElementsByTagName(t)[0];
|
||
g.src = BASE_URL + "/packs/js/sdk.js";
|
||
g.defer = true;
|
||
g.async = true;
|
||
s.parentNode.insertBefore(g, s);
|
||
g.onload = function () {
|
||
window.chatwootSDK.run({
|
||
websiteToken: "svaD5wxfU5UY1r5ZzpMtLqv2",
|
||
baseUrl: BASE_URL,
|
||
});
|
||
window.addEventListener('chatwoot:ready', function() {
|
||
window.$chatwoot.setLocale('${getChatLocale()}');
|
||
window.$chatwoot.setCustomAttributes({
|
||
version: '${packageJson.version}',
|
||
client: 'web',
|
||
});
|
||
});
|
||
};
|
||
})(document, "script");
|
||
`;
|
||
|
||
const scriptElement = document.createElement("script");
|
||
scriptElement.type = "text/javascript";
|
||
scriptElement.text = scriptContent;
|
||
document.head.appendChild(scriptElement);
|
||
}
|
||
export function removeChatBox() {
|
||
const scriptElement = document.querySelector("script[src*='chatwoot']");
|
||
if (scriptElement) {
|
||
scriptElement.remove();
|
||
}
|
||
}
|
||
export const preCacheAllBooks = async (bookList: Book[]) => {
|
||
for (let index = 0; index < bookList.length; index++) {
|
||
const selectedBook = bookList[index];
|
||
if (selectedBook.format === "PDF") {
|
||
continue;
|
||
}
|
||
if (
|
||
await BookUtil.isBookExist(
|
||
"cache-" + selectedBook.key,
|
||
"zip",
|
||
selectedBook.path
|
||
)
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
let result: any = await BookUtil.fetchBook(
|
||
selectedBook.key,
|
||
selectedBook.format.toLowerCase(),
|
||
true,
|
||
selectedBook.path
|
||
);
|
||
let rendition = BookHelper.getRendition(
|
||
result,
|
||
{
|
||
format: selectedBook.format,
|
||
readerMode: "",
|
||
charset: selectedBook.charset,
|
||
animation:
|
||
ConfigService.getReaderConfig("isSliding") === "yes" ? "sliding" : "",
|
||
convertChinese: ConfigService.getReaderConfig("convertChinese"),
|
||
bookLayout: ConfigService.getReaderConfig("bookLayout") || "",
|
||
fullTranslationMode: "no",
|
||
textOrientation: ConfigService.getReaderConfig("textOrientation"),
|
||
parserRegex: "",
|
||
isDarkMode: "no",
|
||
isMobile: "no",
|
||
password: getPdfPassword(selectedBook),
|
||
isScannedPDF:
|
||
selectedBook.description.indexOf("scanned") > -1 ? "yes" : "no",
|
||
},
|
||
Kookit
|
||
);
|
||
let cache = await rendition.preCache(result);
|
||
if (cache !== "err" || cache) {
|
||
await BookUtil.addBook("cache-" + selectedBook.key, "zip", cache);
|
||
toast.dismiss("add-book");
|
||
}
|
||
}
|
||
};
|
||
export const generateSyncRecord = async () => {
|
||
ConfigService.setAllSyncRecord({});
|
||
for (let database of CommonTool.databaseList) {
|
||
let itemList = await DatabaseService.getAllRecords(database);
|
||
for (let item of itemList) {
|
||
ConfigService.setSyncRecord(
|
||
{
|
||
type: "database",
|
||
catergory: "sqlite",
|
||
name: database,
|
||
key: item.key,
|
||
},
|
||
{ operation: "save", time: Date.now() }
|
||
);
|
||
}
|
||
}
|
||
for (let config of CommonTool.configList) {
|
||
if (
|
||
config === "themeColors" ||
|
||
config === "recentBooks" ||
|
||
config === "deletedBooks" ||
|
||
config === "favoriteBooks" ||
|
||
config === "noteTags"
|
||
) {
|
||
if (ConfigService.getAllListConfig(config).length > 0) {
|
||
ConfigService.setSyncRecord(
|
||
{
|
||
type: "config",
|
||
catergory: "listConfig",
|
||
name: "general",
|
||
key: config,
|
||
},
|
||
{
|
||
operation: "update",
|
||
time: Date.now(),
|
||
}
|
||
);
|
||
}
|
||
}
|
||
if (config === "readingTime" || config === "recordLocation") {
|
||
let configItems: string[] = Object.keys(
|
||
ConfigService.getAllObjectConfig(config)
|
||
);
|
||
for (let index = 0; index < configItems.length; index++) {
|
||
let itemName = configItems[index];
|
||
ConfigService.setSyncRecord(
|
||
{
|
||
type: "config",
|
||
catergory: "objectConfig",
|
||
name: config,
|
||
key: itemName,
|
||
},
|
||
{
|
||
operation: "update",
|
||
time: Date.now(),
|
||
}
|
||
);
|
||
}
|
||
}
|
||
if (config === "shelfList") {
|
||
let itemMap = ConfigService.getAllMapConfig(config);
|
||
let itemNameList = Object.keys(itemMap);
|
||
for (let index = 0; index < itemNameList.length; index++) {
|
||
let itemName = itemNameList[index];
|
||
if (itemName === "New") continue;
|
||
ConfigService.setSyncRecord(
|
||
{
|
||
type: "config",
|
||
catergory: "mapConfig",
|
||
name: config,
|
||
key: itemName,
|
||
},
|
||
{
|
||
operation: "update",
|
||
time: Date.now(),
|
||
}
|
||
);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
export const handleContextMenu = (id: string, isInput: boolean = false) => {
|
||
if (!isElectron) return;
|
||
const clipboard = window.require("electron").clipboard;
|
||
const text = clipboard.readText();
|
||
// fill the text into the box
|
||
if (!isInput) {
|
||
let textarea = document.getElementById(id) as HTMLTextAreaElement;
|
||
textarea.value = text;
|
||
textarea.textContent = text;
|
||
triggerReactChange(id, text);
|
||
} else {
|
||
document.getElementById(id)?.setAttribute("value", text);
|
||
triggerReactChange(id, text);
|
||
}
|
||
};
|
||
function triggerReactChange(id: string, value: string) {
|
||
const element: any = document.getElementById(id);
|
||
if (!element) return;
|
||
|
||
// 设置值
|
||
element.value = value;
|
||
|
||
// 创建合成事件对象
|
||
const syntheticEvent = {
|
||
target: {
|
||
id: id,
|
||
value: value,
|
||
},
|
||
currentTarget: {
|
||
value: value,
|
||
},
|
||
preventDefault: () => {},
|
||
stopPropagation: () => {},
|
||
};
|
||
|
||
// 获取 React 实例
|
||
const reactPropKey = Object.keys(element).find((key) =>
|
||
key.startsWith("__reactProps$")
|
||
);
|
||
const reactInstance = reactPropKey ? element[reactPropKey] : null;
|
||
|
||
// 调用 onChange 处理函数
|
||
if (reactInstance && reactInstance.onChange) {
|
||
reactInstance.onChange(syntheticEvent);
|
||
}
|
||
}
|
||
export const getDefaultTransTarget = (langList) => {
|
||
//reverse key and value
|
||
let langMap = {};
|
||
for (let key in langList) {
|
||
langMap[langList[key]] = key;
|
||
}
|
||
|
||
const lang = ConfigService.getReaderConfig("lang");
|
||
const langKeys = Object.keys(langMap);
|
||
let langTarget = langKeys.find((key) =>
|
||
key.includes(KookitConfig.ConvertLangMap[lang])
|
||
);
|
||
return langMap[langTarget || "English"];
|
||
};
|
||
export const WEBSITE_URL = "https://koodoreader.com";
|
||
export const CN_WEBSITE_URL = "https://koodoreader.cn";
|
||
export const getServerRegion = () => {
|
||
let isUseCN = false;
|
||
if (ConfigService.getItem("serverRegion")) {
|
||
isUseCN = ConfigService.getItem("serverRegion") === "china";
|
||
} else {
|
||
if (navigator.language && navigator.language === "zh-CN") {
|
||
isUseCN = true;
|
||
}
|
||
}
|
||
return isUseCN ? "china" : "global";
|
||
};
|
||
export const getWebsiteUrl = () => {
|
||
return getServerRegion() === "china" ? CN_WEBSITE_URL : WEBSITE_URL;
|
||
};
|
||
export const formatTimestamp = (timestamp) => {
|
||
if (!timestamp) return "";
|
||
|
||
const date = new Date(timestamp);
|
||
// return date.toLocaleDateString() + " " + date.toLocaleTimeString();
|
||
return date.toLocaleDateString();
|
||
};
|
||
export const checkMissingBook = async () => {
|
||
if (!isElectron) return;
|
||
var fs = window.require("fs");
|
||
var path = window.require("path");
|
||
let bookList = (await BookUtil.getBookList()) as Book[];
|
||
for (let index = 0; index < bookList.length; index++) {
|
||
const book = bookList[index];
|
||
let fileName = book.key + "." + book.format.toLowerCase();
|
||
let expectedPath = path.join(getStorageLocation() || "", `book`, fileName);
|
||
if (fs.existsSync(expectedPath)) {
|
||
continue;
|
||
}
|
||
// create folder if not exists
|
||
if (!fs.existsSync(path.join(getStorageLocation() || "", "book"))) {
|
||
fs.mkdirSync(path.join(getStorageLocation() || "", "book"), {
|
||
recursive: true,
|
||
});
|
||
}
|
||
if (book.path && fs.existsSync(book.path)) {
|
||
fs.copyFileSync(book.path, expectedPath);
|
||
}
|
||
}
|
||
};
|
||
export const deleteBrokenCovers = () => {
|
||
try {
|
||
if (!isElectron) return;
|
||
var fs = window.require("fs");
|
||
var path = window.require("path");
|
||
const storageLocation = getStorageLocation();
|
||
if (!storageLocation) return;
|
||
const dirPath = path.join(storageLocation, "cover");
|
||
const files = fs.readdirSync(dirPath);
|
||
for (const file of files) {
|
||
const filePath = path.join(dirPath, file);
|
||
try {
|
||
const stat = fs.statSync(filePath);
|
||
if (stat.size === 0) {
|
||
fs.unlinkSync(filePath);
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to check/delete file:", filePath, e);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Error while deleting broken books and covers:", error);
|
||
}
|
||
};
|
||
export const checkBrokenDatabase = async () => {
|
||
let localSyncRecords = ConfigService.getAllSyncRecord();
|
||
let localBooks = Object.keys(localSyncRecords).filter(
|
||
(item) =>
|
||
item.startsWith("database.sqlite.books") &&
|
||
localSyncRecords[item].operation !== "delete"
|
||
);
|
||
let actualbooks = await DatabaseService.getAllRecordKeys("books");
|
||
if (localBooks.length > 0 && actualbooks.length === 0) {
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
export const testConnection = async (driveName: string, driveConfig: any) => {
|
||
toast.loading(i18n.t("Testing connection..."), {
|
||
id: "testing-connection-id",
|
||
});
|
||
if (isElectron) {
|
||
const { ipcRenderer } = window.require("electron");
|
||
const fs = window.require("fs");
|
||
if (!fs.existsSync(getStorageLocation() + "/config")) {
|
||
fs.mkdirSync(getStorageLocation() + "/config", { recursive: true });
|
||
}
|
||
fs.writeFileSync(getStorageLocation() + "/config/test.txt", "Hello world!");
|
||
let result = await ipcRenderer.invoke("cloud-upload", {
|
||
...driveConfig,
|
||
fileName: "test.txt",
|
||
service: driveName,
|
||
type: "config",
|
||
storagePath: getStorageLocation(),
|
||
isUseCache: false,
|
||
});
|
||
if (fs.existsSync(getStorageLocation() + "/config/test.txt")) {
|
||
fs.unlinkSync(getStorageLocation() + "/config/test.txt");
|
||
}
|
||
if (result) {
|
||
toast.success(i18n.t("Connection successful"), {
|
||
id: "testing-connection-id",
|
||
});
|
||
await ipcRenderer.invoke("cloud-delete", {
|
||
...driveConfig,
|
||
fileName: "test.txt",
|
||
service: driveName,
|
||
type: "config",
|
||
storagePath: getStorageLocation(),
|
||
isUseCache: false,
|
||
});
|
||
} else {
|
||
toast.error(i18n.t("Connection failed"), {
|
||
id: "testing-connection-id",
|
||
});
|
||
}
|
||
|
||
return result;
|
||
} else {
|
||
let syncUtil = new SyncUtil(driveName, driveConfig);
|
||
// 上传到云端
|
||
let result = await syncUtil.uploadFile(
|
||
"test.txt",
|
||
"config",
|
||
new Blob(["Hello world!"])
|
||
);
|
||
if (!result) {
|
||
toast.error(i18n.t("Connection failed"), {
|
||
id: "testing-connection-id",
|
||
});
|
||
return false;
|
||
} else {
|
||
toast.success(i18n.t("Connection successful"), {
|
||
id: "testing-connection-id",
|
||
});
|
||
}
|
||
|
||
// 删除云端文件
|
||
return await syncUtil.deleteFile("test.txt", "config");
|
||
}
|
||
};
|
||
export const testCORS = async (url: string) => {
|
||
if (isElectron) return true;
|
||
|
||
try {
|
||
const response = await fetch(url, {
|
||
method: "GET", // 或 'POST' 等
|
||
mode: "cors", // 明确指定跨域模式
|
||
headers: {
|
||
"Content-Type": "application/json", // 如果是POST,可添加
|
||
},
|
||
// body: JSON.stringify({ test: 'data' }) // 如果是POST
|
||
});
|
||
if (response.ok) {
|
||
const data = await response.text();
|
||
return true;
|
||
} else {
|
||
return true;
|
||
}
|
||
} catch (error) {
|
||
if (window.location.href.startsWith("https://")) {
|
||
if (url.startsWith("http://")) {
|
||
toast.error(
|
||
i18n.t(
|
||
"This data source cannot be accessed due to browser's security policy. Please switch to another data source or HTTPS-based service provider."
|
||
),
|
||
{
|
||
duration: 8000,
|
||
}
|
||
);
|
||
return false;
|
||
}
|
||
} else {
|
||
toast.error(
|
||
i18n.t(
|
||
"This data source cannot be accessed from browser due to CORS policy. Please switch to another data source or CORS-enabled service provider."
|
||
) +
|
||
" " +
|
||
i18n.t("You can also use the desktop app to avoid this issue.")
|
||
);
|
||
}
|
||
|
||
console.error("CORS not supported:", error);
|
||
return false;
|
||
}
|
||
};
|
||
export const getPdfPassword = (book: Book) => {
|
||
if (book.format !== "PDF" || !book?.description) return "";
|
||
// 匹配形如 protected PDF: #password# 的内容
|
||
const match = book.description.match(/protected PDF: #(.+?)#/);
|
||
return match ? match[1] : "";
|
||
};
|
||
export const showDownloadProgress = (
|
||
service: string,
|
||
type: string,
|
||
bookSize: number
|
||
) => {
|
||
if (bookSize === 0) {
|
||
return setTimeout(() => {
|
||
console.warn("Book size is 0, skipping download progress.");
|
||
}, 1000);
|
||
}
|
||
let isFirst = true;
|
||
let timer = setInterval(async () => {
|
||
let downloadedSize = 0;
|
||
if (isElectron) {
|
||
if (type === "cloud") {
|
||
let tokenConfig = await getCloudConfig(service);
|
||
let config = {
|
||
...tokenConfig,
|
||
service: service,
|
||
storagePath: getStorageLocation(),
|
||
};
|
||
downloadedSize = await window
|
||
.require("electron")
|
||
.ipcRenderer.invoke("cloud-progress", config);
|
||
} else {
|
||
let tokenConfig = await getCloudConfig(service);
|
||
downloadedSize = await window
|
||
.require("electron")
|
||
.ipcRenderer.invoke("picker-progress", {
|
||
...tokenConfig,
|
||
baseFolder: "",
|
||
service: service,
|
||
currentPath: "",
|
||
storagePath: getStorageLocation(),
|
||
});
|
||
}
|
||
if (isFirst && downloadedSize > 0) {
|
||
downloadedSize = 0;
|
||
isFirst = false;
|
||
}
|
||
let progress = downloadedSize / bookSize;
|
||
toast.loading(
|
||
i18n.t("Downloading") + " (" + parseInt(progress * 100 + "") + "%)",
|
||
{
|
||
id: "offline-book",
|
||
}
|
||
);
|
||
} else {
|
||
if (type === "cloud") {
|
||
let syncUtil = await SyncService.getSyncUtil();
|
||
downloadedSize = await syncUtil.getDownloadedSize();
|
||
} else {
|
||
let pickerUtil = await SyncService.getPickerUtil(service);
|
||
downloadedSize = await pickerUtil.getDownloadedSize();
|
||
}
|
||
if (isFirst && downloadedSize > 0) {
|
||
downloadedSize = 0;
|
||
isFirst = false;
|
||
}
|
||
let progress = downloadedSize / bookSize;
|
||
toast.loading(
|
||
i18n.t("Downloading") + " (" + parseInt(progress * 100 + "") + "%)",
|
||
{
|
||
id: "offline-book",
|
||
}
|
||
);
|
||
}
|
||
}, 500);
|
||
return timer;
|
||
};
|
||
export const showTaskProgress = async (
|
||
handleSyncStateChange: (isSync: boolean) => void
|
||
) => {
|
||
let config = {};
|
||
let timer: any;
|
||
let service = ConfigService.getItem("defaultSyncOption");
|
||
if (!service) {
|
||
toast(i18n.t("Please add data source in the setting"));
|
||
return null;
|
||
}
|
||
if (isElectron) {
|
||
let tokenConfig = await getCloudConfig(service);
|
||
config = {
|
||
...tokenConfig,
|
||
service: service,
|
||
storagePath: getStorageLocation(),
|
||
};
|
||
await window.require("electron").ipcRenderer.invoke("cloud-reset", config);
|
||
} else {
|
||
let syncUtil = await SyncService.getSyncUtil();
|
||
syncUtil.resetCounters();
|
||
}
|
||
timer = setInterval(async () => {
|
||
if (isElectron) {
|
||
let stats = await window
|
||
.require("electron")
|
||
.ipcRenderer.invoke("cloud-stats", config);
|
||
if (stats.total > 0) {
|
||
toast.loading(
|
||
i18n.t("Start Transferring Data") +
|
||
" (" +
|
||
stats.completed +
|
||
"/" +
|
||
stats.total +
|
||
")" +
|
||
" (" +
|
||
i18n.t(
|
||
driveList.find(
|
||
(item) =>
|
||
item.value === ConfigService.getItem("defaultSyncOption")
|
||
)?.label || ""
|
||
) +
|
||
")",
|
||
{
|
||
id: "syncing",
|
||
position: "bottom-center",
|
||
}
|
||
);
|
||
}
|
||
} else {
|
||
let syncUtil = await SyncService.getSyncUtil();
|
||
let stats = await syncUtil.getStats();
|
||
if (stats.total > 0) {
|
||
toast.loading(
|
||
i18n.t("Start Transferring Data") +
|
||
" (" +
|
||
stats.completed +
|
||
"/" +
|
||
stats.total +
|
||
")" +
|
||
" (" +
|
||
i18n.t(
|
||
driveList.find(
|
||
(item) =>
|
||
item.value === ConfigService.getItem("defaultSyncOption")
|
||
)?.label || ""
|
||
) +
|
||
")",
|
||
{
|
||
id: "syncing",
|
||
position: "bottom-center",
|
||
}
|
||
);
|
||
}
|
||
}
|
||
}, 1000);
|
||
return timer;
|
||
};
|
||
export const getTaskStats = async () => {
|
||
let service = ConfigService.getItem("defaultSyncOption");
|
||
if (!service) {
|
||
toast(i18n.t("Please add data source in the setting"));
|
||
return {};
|
||
}
|
||
if (isElectron) {
|
||
let tokenConfig = await getCloudConfig(service);
|
||
let config = {
|
||
...tokenConfig,
|
||
service: service,
|
||
storagePath: getStorageLocation(),
|
||
};
|
||
return await window
|
||
.require("electron")
|
||
.ipcRenderer.invoke("cloud-stats", config);
|
||
} else {
|
||
let syncUtil = await SyncService.getSyncUtil();
|
||
return await syncUtil.getStats();
|
||
}
|
||
};
|
||
export const compareVersions = (version1: string, version2: string) => {
|
||
// Split strings by '.' and convert segments to numbers
|
||
const parts1 = version1.split(".").map(Number);
|
||
const parts2 = version2.split(".").map(Number);
|
||
|
||
// Determine the maximum length to handle unequal segment counts
|
||
const maxLength = Math.max(parts1.length, parts2.length);
|
||
|
||
for (let i = 0; i < maxLength; i++) {
|
||
// Use 0 for missing segments in shorter versions
|
||
const part1 = parts1[i] || 0;
|
||
const part2 = parts2[i] || 0;
|
||
|
||
if (part1 > part2) {
|
||
return 1; // version1 is greater
|
||
}
|
||
if (part1 < part2) {
|
||
return -1; // version2 is greater
|
||
}
|
||
}
|
||
|
||
return 0; // Versions are equal
|
||
};
|
||
export const clearAllData = async () => {
|
||
let deviceUuid = "";
|
||
if (!isElectron) {
|
||
deviceUuid = ConfigService.getItem("fingerPrint") || "";
|
||
}
|
||
localStorage.clear();
|
||
sessionStorage.clear();
|
||
if (deviceUuid && !isElectron) {
|
||
ConfigService.setItem("fingerPrint", deviceUuid);
|
||
}
|
||
//clear all indexed db data
|
||
|
||
if (isElectron) {
|
||
let storageLocation = getStorageLocation();
|
||
const fs = window.require("fs");
|
||
let databaseList = CommonTool.databaseList;
|
||
for (let i = 0; i < databaseList.length; i++) {
|
||
await window.require("electron").ipcRenderer.invoke("close-database", {
|
||
dbName: databaseList[i],
|
||
storagePath: getStorageLocation(),
|
||
});
|
||
}
|
||
if (fs.existsSync(storageLocation)) {
|
||
fs.rmSync(storageLocation, { recursive: true, force: true });
|
||
}
|
||
const { ipcRenderer } = window.require("electron");
|
||
ipcRenderer.invoke("clear-all-data", {});
|
||
}
|
||
await localforage.clear();
|
||
};
|
||
export const resetKoodoSync = async () => {
|
||
let encryptToken = await TokenService.getToken(
|
||
ConfigService.getItem("defaultSyncOption") + "_token"
|
||
);
|
||
await updateUserConfig({
|
||
is_enable_koodo_sync: "no",
|
||
default_sync_option: ConfigService.getItem("defaultSyncOption"),
|
||
default_sync_token: encryptToken || "",
|
||
});
|
||
setTimeout(() => {
|
||
updateUserConfig({
|
||
is_enable_koodo_sync: "yes",
|
||
default_sync_option: ConfigService.getItem("defaultSyncOption"),
|
||
default_sync_token: encryptToken || "",
|
||
});
|
||
}, 1000);
|
||
};
|
||
export const handleAutoCloudSync = async () => {
|
||
let syncRes = await getCloudSyncToken();
|
||
if (
|
||
syncRes.code === 200 &&
|
||
syncRes.data.default_sync_option &&
|
||
syncRes.data.default_sync_option !== "icloud" &&
|
||
syncRes.data.default_sync_token
|
||
) {
|
||
let supportedSources = driveList
|
||
.filter((item) => {
|
||
if (isElectron) {
|
||
return item.support.includes("desktop");
|
||
} else {
|
||
return item.support.includes("browser");
|
||
}
|
||
})
|
||
.map((item) => item.value);
|
||
if (!supportedSources.includes(syncRes.data.default_sync_option)) {
|
||
return false;
|
||
}
|
||
if (
|
||
!isElectron &&
|
||
(syncRes.data.default_sync_option === "webdav" ||
|
||
syncRes.data.default_sync_option === "s3compatible")
|
||
) {
|
||
return false;
|
||
}
|
||
ConfigService.setItem(
|
||
"defaultSyncOption",
|
||
syncRes.data.default_sync_option
|
||
);
|
||
ConfigService.setReaderConfig("isEnableKoodoSync", "yes");
|
||
await TokenService.setToken(
|
||
syncRes.data.default_sync_option + "_token",
|
||
syncRes.data.default_sync_token
|
||
);
|
||
ConfigService.setListConfig(
|
||
syncRes.data.default_sync_option,
|
||
"dataSourceList"
|
||
);
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
export const detectLocalLanguage = (text: string): string => {
|
||
const chinesePattern = /[\u4e00-\u9fff\u3000-\u303f\uf900-\ufaff]/g;
|
||
const japanesePattern = /[\u3040-\u309f\u30a0-\u30ff]/g;
|
||
const koreanPattern = /[\uac00-\ud7af\u1100-\u11ff]/g;
|
||
|
||
const chineseCount = (text.match(chinesePattern) || []).length;
|
||
const japaneseCount = (text.match(japanesePattern) || []).length;
|
||
const koreanCount = (text.match(koreanPattern) || []).length;
|
||
|
||
const cjkTotal = chineseCount + japaneseCount + koreanCount;
|
||
if (cjkTotal / text.length <= 0.3) return "en";
|
||
|
||
if (chineseCount >= japaneseCount && chineseCount >= koreanCount) return "zh";
|
||
if (japaneseCount >= chineseCount && japaneseCount >= koreanCount)
|
||
return "ja";
|
||
return "ko";
|
||
};
|
||
export const getParserRegex = (extension: string, bookKey?: string) => {
|
||
let parserRegex = "";
|
||
if (extension.toLowerCase().endsWith("txt")) {
|
||
let defaultTxtParser = "Default parser";
|
||
// Per-book parser overrides the global setting
|
||
if (bookKey) {
|
||
const rule = ConfigService.getObjectConfig(bookKey, "bookRules", {});
|
||
if (rule?.defaultTxtParser) {
|
||
defaultTxtParser = rule.defaultTxtParser;
|
||
}
|
||
}
|
||
let txtParsers: any[] = [
|
||
...Object.values(ConfigService.getAllObjectConfig("txtParsers")),
|
||
...KookitConfig.ContentRegxConfig,
|
||
];
|
||
let txtParser = txtParsers.find(
|
||
(parser) => parser.value === defaultTxtParser
|
||
);
|
||
if (txtParser) {
|
||
parserRegex = txtParser.regex;
|
||
}
|
||
}
|
||
return parserRegex;
|
||
};
|
||
export const parseColorInput = (input: string): string | null => {
|
||
const trimmed = input.trim();
|
||
// Hex format: #rgb or #rrggbb
|
||
if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) {
|
||
return (
|
||
"#" +
|
||
trimmed
|
||
.slice(1)
|
||
.split("")
|
||
.map((c) => c + c)
|
||
.join("")
|
||
.toLowerCase()
|
||
);
|
||
}
|
||
if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) {
|
||
return trimmed.toLowerCase();
|
||
}
|
||
// rgb/rgba format
|
||
const match = trimmed.match(
|
||
/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/
|
||
);
|
||
if (match) {
|
||
return (
|
||
"#" +
|
||
[match[1], match[2], match[3]]
|
||
.map((v) =>
|
||
Math.max(0, Math.min(255, parseInt(v, 10)))
|
||
.toString(16)
|
||
.padStart(2, "0")
|
||
)
|
||
.join("")
|
||
);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
export const normalizePickerColor = (
|
||
color: string | undefined,
|
||
fallback: string
|
||
): string => {
|
||
if (!color) {
|
||
return fallback;
|
||
}
|
||
|
||
if (color.startsWith("#")) {
|
||
const hex = color.slice(1);
|
||
if (/^[0-9a-fA-F]{6}$/.test(hex)) {
|
||
return `#${hex.toLowerCase()}`;
|
||
}
|
||
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
|
||
return `#${hex
|
||
.split("")
|
||
.map((item) => item + item)
|
||
.join("")
|
||
.toLowerCase()}`;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
const match = color.match(
|
||
/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)/
|
||
);
|
||
if (!match) {
|
||
return fallback;
|
||
}
|
||
|
||
return `#${[match[1], match[2], match[3]]
|
||
.map((item) => {
|
||
const value = Math.max(0, Math.min(255, parseInt(item, 10)));
|
||
return value.toString(16).padStart(2, "0");
|
||
})
|
||
.join("")}`;
|
||
};
|
||
export const splitSentences = (text: string, maxLength?: number) => {
|
||
const lang = detectLocalLanguage(text);
|
||
const resolvedMaxLength = maxLength ?? (lang === "en" ? 150 : 50);
|
||
|
||
const segmenter = new (Intl as any).Segmenter(lang, {
|
||
granularity: "sentence",
|
||
});
|
||
const segments = segmenter.segment(text);
|
||
|
||
const sentences = Array.from(segments).map((s: any) => s.segment);
|
||
const trimmed = sentences
|
||
.map((sentence) => sentence.trim())
|
||
.filter((sentence) => sentence.trim() !== "");
|
||
const splitLongSentence = (sentence: string): string[] => {
|
||
if (sentence.length <= resolvedMaxLength) return [sentence];
|
||
|
||
// Try splitting by common punctuation marks (Chinese and Western)
|
||
const parts = sentence
|
||
.split(/(?<=[,,;;::、…])/)
|
||
.map((s) => s.trim())
|
||
.filter((s) => s.length > 0);
|
||
|
||
if (parts.length > 1) {
|
||
// Greedily merge parts to minimize the number of resulting chunks
|
||
const result: string[] = [];
|
||
let current = "";
|
||
for (const part of parts) {
|
||
const candidate = current ? current + part : part;
|
||
if (candidate.length <= resolvedMaxLength) {
|
||
current = candidate;
|
||
} else {
|
||
if (current) result.push(current);
|
||
// If a single part already exceeds maxLength, keep it as-is
|
||
current = part;
|
||
}
|
||
}
|
||
if (current) result.push(current);
|
||
return result;
|
||
}
|
||
|
||
// No punctuation found, keep the sentence as-is
|
||
return [sentence];
|
||
};
|
||
|
||
return trimmed
|
||
.flatMap(splitLongSentence)
|
||
.filter((sentence) => /[\p{L}\p{N}]/u.test(sentence));
|
||
};
|
||
export const trimSpecialCharacters = (text: string) => {
|
||
return text.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
|
||
};
|
||
export const checkReachPageEnd = (
|
||
nodeIndex: number,
|
||
nodeList: {
|
||
text: string;
|
||
voiceName: string;
|
||
voiceEngine: string;
|
||
}[],
|
||
visibleTextList: string[]
|
||
) => {
|
||
if (visibleTextList.length === 0) return true;
|
||
let nodeTextList = nodeList.map((node) => node.text);
|
||
let lastMatchIndex = findLastMatchIndex(nodeTextList, visibleTextList);
|
||
console.log(lastMatchIndex, nodeIndex, "lastMatchIndex");
|
||
return lastMatchIndex === nodeIndex;
|
||
};
|
||
export const findLastMatchIndex = (a: string[], b: string[]) => {
|
||
let lastMatchIndex = -1;
|
||
let aIndex = 0;
|
||
|
||
for (let i = 0; i < b.length; i++) {
|
||
// 从当前 aIndex 开始在 a 中查找 b[i]
|
||
let found = false;
|
||
for (let j = aIndex; j < a.length; j++) {
|
||
if (a[j] === b[i]) {
|
||
lastMatchIndex = j;
|
||
aIndex = j + 1;
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
// 如果没找到,继续查找下一个 b 元素
|
||
}
|
||
|
||
return lastMatchIndex;
|
||
};
|
||
export const getICloudDrivePath = () => {
|
||
if (!isElectron) return "";
|
||
const fs = window.require("fs");
|
||
const path = window.require("path");
|
||
const os = window.require("os");
|
||
|
||
let iCloudPath = "";
|
||
|
||
// 自动检测iCloud Drive路径
|
||
if (isElectron && process.platform === "darwin") {
|
||
// macOS
|
||
const possiblePath = path.join(
|
||
os.homedir(),
|
||
"Library",
|
||
"Mobile Documents",
|
||
"iCloud~com~koodoreader~expo",
|
||
"Documents"
|
||
);
|
||
if (fs.existsSync(possiblePath)) {
|
||
iCloudPath = possiblePath;
|
||
}
|
||
}
|
||
|
||
// 如果自动检测失败,弹窗让用户手动选择
|
||
if (!iCloudPath || !fs.existsSync(iCloudPath)) {
|
||
return "";
|
||
}
|
||
|
||
// 验证路径是否有效
|
||
if (iCloudPath && fs.existsSync(iCloudPath)) {
|
||
return iCloudPath;
|
||
}
|
||
return "";
|
||
};
|
||
export const prepareThirdConfig = async (service: string, config: any) => {
|
||
if (
|
||
service === "adrive" ||
|
||
service === "boxnet" ||
|
||
service === "dropbox" ||
|
||
service === "dubox" ||
|
||
service === "google" ||
|
||
service === "microsoft" ||
|
||
service === "microsoft_exp" ||
|
||
service === "google_exp" ||
|
||
service === "yandex" ||
|
||
service === "yiyiwu"
|
||
) {
|
||
if (
|
||
config.access_token &&
|
||
config.expires_at > new Date().getTime() + 15 * 60 * 1000
|
||
) {
|
||
return config;
|
||
}
|
||
|
||
// Get access token
|
||
let refreshToken = config.refresh_token;
|
||
let res = await refreshThirdToken(service, refreshToken);
|
||
if (!res.data || !res.data.access_token) {
|
||
toast.error(
|
||
i18n.t(
|
||
"The authentication token for your data source is no longer valid, please reauthorize in the settings"
|
||
),
|
||
{
|
||
id: "syncing",
|
||
duration: 6000,
|
||
}
|
||
);
|
||
let targetDrive = service;
|
||
await TokenService.setToken(targetDrive + "_token", "");
|
||
SyncService.removeSyncUtil(targetDrive);
|
||
removeCloudConfig(targetDrive);
|
||
if (isElectron) {
|
||
const { ipcRenderer } = window.require("electron");
|
||
await ipcRenderer.invoke("cloud-close", {
|
||
service: targetDrive,
|
||
});
|
||
}
|
||
ConfigService.deleteListConfig(targetDrive, "dataSourceList");
|
||
if (targetDrive === ConfigService.getItem("defaultSyncOption")) {
|
||
ConfigService.removeItem("defaultSyncOption");
|
||
}
|
||
reloadManager();
|
||
return {};
|
||
}
|
||
if (
|
||
service === "adrive" ||
|
||
service === "boxnet" ||
|
||
service === "dubox" ||
|
||
service === "yiyiwu"
|
||
) {
|
||
config.refresh_token = res.data.refresh_token;
|
||
config.access_token = res.data.access_token;
|
||
config.expires_at = new Date().getTime() + res.data.expires_in * 1000;
|
||
} else {
|
||
config.access_token = res.data.access_token;
|
||
config.expires_at = new Date().getTime() + res.data.expires_in * 1000;
|
||
}
|
||
let response: any = await encryptToken(service, config);
|
||
if (response.code === 200) {
|
||
if (
|
||
ConfigService.getReaderConfig("isEnableKoodoSync") === "yes" &&
|
||
ConfigService.getItem("defaultSyncOption") === service
|
||
) {
|
||
let syncToken = await TokenService.getToken(service + "_token");
|
||
await updateUserConfig({
|
||
is_enable_koodo_sync: "yes",
|
||
default_sync_option: service,
|
||
default_sync_token: syncToken || "",
|
||
});
|
||
}
|
||
}
|
||
SyncService.removeSyncUtil(service);
|
||
removeCloudConfig(service);
|
||
if (isElectron) {
|
||
const { ipcRenderer } = window.require("electron");
|
||
await ipcRenderer.invoke("cloud-close", {
|
||
service: service,
|
||
});
|
||
}
|
||
|
||
return config;
|
||
} else {
|
||
return config;
|
||
}
|
||
};
|
||
export const isTokenExpired = async (service: string): Promise<boolean> => {
|
||
let config = await getCloudToken(service);
|
||
if (!config) {
|
||
return false;
|
||
}
|
||
|
||
if (
|
||
service === "adrive" ||
|
||
service === "boxnet" ||
|
||
service === "dropbox" ||
|
||
service === "dubox" ||
|
||
service === "google" ||
|
||
service === "microsoft" ||
|
||
service === "microsoft_exp" ||
|
||
service === "google_exp" ||
|
||
service === "yandex" ||
|
||
service === "yiyiwu"
|
||
) {
|
||
if (
|
||
config.access_token &&
|
||
config.expires_at > new Date().getTime() + 15 * 60 * 1000
|
||
) {
|
||
return false;
|
||
}
|
||
return true;
|
||
} else {
|
||
return false;
|
||
}
|
||
};
|
||
export const langToName = (lang: string) => {
|
||
let regionCode = lang.split("-")[1];
|
||
let langCode = lang.split("-")[0];
|
||
if (!languageENMap["languages"][langCode]) {
|
||
return lang;
|
||
}
|
||
if (ConfigService.getReaderConfig("lang").startsWith("zh")) {
|
||
return (
|
||
languageCNMap["languages"][langCode] +
|
||
(regionCode && languageCNMap["territories"][regionCode]
|
||
? " (" + languageCNMap["territories"][regionCode] + ")"
|
||
: "")
|
||
);
|
||
} else {
|
||
return (
|
||
languageENMap["languages"][langCode] +
|
||
(regionCode && languageENMap["territories"][regionCode]
|
||
? " (" + languageENMap["territories"][regionCode] + ")"
|
||
: "")
|
||
);
|
||
}
|
||
};
|