diff --git a/routes.json b/routes.json
index 97fd2bc..1fb9273 100644
--- a/routes.json
+++ b/routes.json
@@ -33,5 +33,10 @@
"path": "guides/reader-settings",
"title": "Reader settings | LNReader",
"description": "This section relates to the reading experience in the app and navigating the reader."
+ },
+ {
+ "path": "guides/upgrade",
+ "title": "Upgrade helper | LNReader",
+ "description": "Instructions to migrate app from old version to the latest."
}
]
\ No newline at end of file
diff --git a/src/App.tsx b/src/App.tsx
index 7e76e65..dd08d89 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -11,6 +11,7 @@ import Changelogs from "@routes/changelogs";
import Contribute from "@routes/contribute";
import Plugins from "@routes/plugins";
import NotFound from "./404";
+import Upgrade from "@routes/guides/upgrade";
function App() {
const theme = useTheme();
@@ -84,6 +85,7 @@ function App() {
} />
} />
} />
+ } />
diff --git a/src/components/SideBar/index.tsx b/src/components/SideBar/index.tsx
index 4a4c2ac..abcf378 100644
--- a/src/components/SideBar/index.tsx
+++ b/src/components/SideBar/index.tsx
@@ -31,6 +31,7 @@ const guideNavs = [
{ title: "Getting started", link: "/guides/getting-started" },
{ title: "Backups", link: "/guides/backups" },
{ title: "Reader settings", link: "/guides/reader-settings" },
+ { title: "Upgrade", link: "/guides/upgrade" },
];
export default function SideBar(props: Props) {
diff --git a/src/routes/guides/upgrade/index.tsx b/src/routes/guides/upgrade/index.tsx
new file mode 100644
index 0000000..9a48b4d
--- /dev/null
+++ b/src/routes/guides/upgrade/index.tsx
@@ -0,0 +1,294 @@
+import Layout from "@components/Layout";
+import Page from "@components/Page";
+import {
+ Alert,
+ Box,
+ Button,
+ IconButton,
+ styled,
+ Typography,
+} from "@mui/material";
+import UploadFileRoundedIcon from "@mui/icons-material/UploadFileRounded";
+import SimCardDownloadIcon from "@mui/icons-material/SimCardDownload";
+import { useTheme } from "@hooks/useTheme";
+import { useEffect, useState } from "react";
+import { NovelInfo, PluginItem } from "../../../types";
+import PublicIcon from "@mui/icons-material/Public";
+
+interface OldNovelInfo {
+ novelId: number;
+ sourceUrl: string;
+ novelUrl: string;
+ sourceId: number;
+ source: string;
+ novelName: string;
+ novelCover?: string;
+ novelSummary?: string;
+ genre?: string;
+ author?: string;
+ status?: string;
+ followed: number;
+ categoryIds: string;
+}
+
+const VisuallyHiddenInput = styled("input")({
+ clip: "rect(0 0 0 0)",
+ clipPath: "inset(50%)",
+ height: 1,
+ overflow: "hidden",
+ position: "absolute",
+ bottom: 0,
+ left: 0,
+ whiteSpace: "nowrap",
+ width: 1,
+});
+
+const isUrlAbsolute = (url: string) => {
+ if (url) {
+ if (url.indexOf("//") === 0) {
+ return true;
+ } // URL is protocol-relative (= absolute)
+ if (url.indexOf("://") === -1) {
+ return false;
+ } // URL has no protocol (= relative)
+ if (url.indexOf(".") === -1) {
+ return false;
+ } // URL does not contain a dot, i.e. no TLD (= relative, possibly REST)
+ if (url.indexOf("/") === -1) {
+ return false;
+ } // URL does not contain a single slash (= relative)
+ if (url.indexOf(":") > url.indexOf("/")) {
+ return false;
+ } // The first colon comes after the first slash (= relative)
+ if (url.indexOf("://") < url.indexOf(".")) {
+ return true;
+ } // Protocol is defined before first dot (= absolute)
+ }
+ return false; // Anything else must be relative
+};
+
+export default function Upgrade() {
+ const theme = useTheme();
+ const [plugins, setPlugins] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [migratedNovels, setMigratedNovel] = useState([]);
+ const [requiredPlugins, setRequiredPlugins] = useState([]);
+ const [showAlert, setShowAlert] = useState(false);
+ useEffect(() => {
+ fetch(
+ "https://raw.githubusercontent.com/LNReader/lnreader-plugins/plugins/v3.0.0/.dist/plugins.min.json"
+ )
+ .then((res) => res.json())
+ .then((plugins) => {
+ setPlugins(plugins);
+ setLoading(false);
+ });
+ }, []);
+
+ const findSuitedPlugin = (novel: OldNovelInfo) => {
+ let novelSiteUrl;
+ try {
+ novelSiteUrl = new URL(novel.sourceUrl);
+ } catch {
+ return undefined;
+ }
+ const novelSiteDomain = novelSiteUrl.hostname.replace(/www\./, "");
+ for (const plugin of plugins) {
+ const pluginSiteUrl = new URL(plugin.site);
+ const pluginSiteDomain = pluginSiteUrl.hostname.replace(/www\./, "");
+ if (pluginSiteDomain === novelSiteDomain) {
+ return plugin;
+ }
+ }
+
+ return undefined;
+ };
+
+ const migrateNovels = (oldNovels: OldNovelInfo[]) => {
+ const migratedNovels: NovelInfo[] = [];
+ const requiredPlugins = new Set();
+ for (const oldNovel of oldNovels) {
+ const plugin = findSuitedPlugin(oldNovel);
+ let novelUrl = oldNovel.novelUrl;
+ if (plugin) {
+ if (isUrlAbsolute(novelUrl)) {
+ novelUrl = oldNovel.novelUrl.replace(plugin.site, "");
+ }
+ if (plugin.id === "boxnovel") {
+ novelUrl = "novel/" + novelUrl + "/";
+ }
+ migratedNovels.push({
+ id: oldNovel.novelId,
+ path: novelUrl,
+ pluginId: plugin.id,
+ name: oldNovel.novelName,
+ cover: oldNovel.novelCover,
+ summary: oldNovel.novelSummary,
+ author: oldNovel.author,
+ status: oldNovel.status,
+ genres: oldNovel.genre,
+ inLibrary: Boolean(oldNovel.followed),
+ isLocal: false,
+ totalPages: 0,
+ });
+ requiredPlugins.add(plugin);
+ }
+ }
+
+ setMigratedNovel(migratedNovels);
+ setRequiredPlugins(Array.from(requiredPlugins));
+ };
+
+ const PluginCard = ({ plugin }: { plugin: PluginItem }) => {
+ return (
+
+ );
+ };
+
+ useEffect(() => {
+ if (showAlert) {
+ setTimeout(() => setShowAlert(false), 1000);
+ }
+ }, [showAlert]);
+
+ return (
+
+
+ {showAlert ? (
+
+ Copied name.
+
+ ) : null}
+
+
+ }
+ disabled={loading}
+ sx={{ textTransform: "none" }}
+ >
+ Upload 1.x.x backup file
+ {
+ try {
+ setLoading(true);
+ const file = ev.target.files?.[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ if (typeof e.target?.result === "string") {
+ const oldNovels = JSON.parse(e.target.result);
+ migrateNovels(oldNovels);
+ }
+ } catch (e) {
+ alert(e);
+ }
+ };
+ reader.readAsText(file);
+ }
+ } finally {
+ setLoading(false);
+ }
+ }}
+ />
+
+ {!loading && migratedNovels.length ? (
+
+ }
+ disabled={loading}
+ sx={{
+ textTransform: "none",
+ bgcolor: theme.tertiary,
+ color: theme.onTertiary,
+ }}
+ onClick={() => {
+ if (migratedNovels.length) {
+ const migratedBlob = new Blob(
+ [JSON.stringify(migratedNovels)],
+ {
+ type: "application/json",
+ }
+ );
+ const link = document.createElement("a");
+ link.href = window.URL.createObjectURL(migratedBlob);
+ link.setAttribute("download", `migrated-backup.json`);
+ document.body.appendChild(link);
+ link.click();
+ link.parentNode?.removeChild(link);
+ }
+ }}
+ >
+ Download ({migratedNovels.length} novels)
+
+ ) : null}
+
+
+ Required plugins
+ {requiredPlugins.map((plugin) => (
+
+ ))}
+
+
+ }
+ />
+
+ );
+}
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..45bd33d
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,34 @@
+export interface PluginItem {
+ id: string;
+ name: string;
+ version: string;
+ iconUrl: string;
+ site: string;
+ lang: string;
+}
+
+export enum NovelStatus {
+ Unknown = "Unknown",
+ Ongoing = "Ongoing",
+ Completed = "Completed",
+ Licensed = "Licensed",
+ PublishingFinished = "Publishing Finished",
+ Cancelled = "Cancelled",
+ OnHiatus = "On Hiatus",
+}
+
+export interface NovelInfo {
+ id: number;
+ path: string;
+ pluginId: string;
+ name: string;
+ cover?: string;
+ summary?: string;
+ author?: string;
+ artist?: string;
+ status?: NovelStatus | string;
+ genres?: string;
+ inLibrary: boolean;
+ isLocal: boolean;
+ totalPages: number;
+}