|
| 1 | +import Layout from "@components/Layout"; |
| 2 | +import Page from "@components/Page"; |
| 3 | +import { |
| 4 | + Alert, |
| 5 | + Box, |
| 6 | + Button, |
| 7 | + IconButton, |
| 8 | + styled, |
| 9 | + Typography, |
| 10 | +} from "@mui/material"; |
| 11 | +import UploadFileRoundedIcon from "@mui/icons-material/UploadFileRounded"; |
| 12 | +import SimCardDownloadIcon from "@mui/icons-material/SimCardDownload"; |
| 13 | +import { useTheme } from "@hooks/useTheme"; |
| 14 | +import { useEffect, useState } from "react"; |
| 15 | +import { NovelInfo, PluginItem } from "../../../types"; |
| 16 | +import PublicIcon from "@mui/icons-material/Public"; |
| 17 | + |
| 18 | +interface OldNovelInfo { |
| 19 | + novelId: number; |
| 20 | + sourceUrl: string; |
| 21 | + novelUrl: string; |
| 22 | + sourceId: number; |
| 23 | + source: string; |
| 24 | + novelName: string; |
| 25 | + novelCover?: string; |
| 26 | + novelSummary?: string; |
| 27 | + genre?: string; |
| 28 | + author?: string; |
| 29 | + status?: string; |
| 30 | + followed: number; |
| 31 | + categoryIds: string; |
| 32 | +} |
| 33 | + |
| 34 | +const VisuallyHiddenInput = styled("input")({ |
| 35 | + clip: "rect(0 0 0 0)", |
| 36 | + clipPath: "inset(50%)", |
| 37 | + height: 1, |
| 38 | + overflow: "hidden", |
| 39 | + position: "absolute", |
| 40 | + bottom: 0, |
| 41 | + left: 0, |
| 42 | + whiteSpace: "nowrap", |
| 43 | + width: 1, |
| 44 | +}); |
| 45 | + |
| 46 | +const isUrlAbsolute = (url: string) => { |
| 47 | + if (url) { |
| 48 | + if (url.indexOf("//") === 0) { |
| 49 | + return true; |
| 50 | + } // URL is protocol-relative (= absolute) |
| 51 | + if (url.indexOf("://") === -1) { |
| 52 | + return false; |
| 53 | + } // URL has no protocol (= relative) |
| 54 | + if (url.indexOf(".") === -1) { |
| 55 | + return false; |
| 56 | + } // URL does not contain a dot, i.e. no TLD (= relative, possibly REST) |
| 57 | + if (url.indexOf("/") === -1) { |
| 58 | + return false; |
| 59 | + } // URL does not contain a single slash (= relative) |
| 60 | + if (url.indexOf(":") > url.indexOf("/")) { |
| 61 | + return false; |
| 62 | + } // The first colon comes after the first slash (= relative) |
| 63 | + if (url.indexOf("://") < url.indexOf(".")) { |
| 64 | + return true; |
| 65 | + } // Protocol is defined before first dot (= absolute) |
| 66 | + } |
| 67 | + return false; // Anything else must be relative |
| 68 | +}; |
| 69 | + |
| 70 | +export default function Upgrade() { |
| 71 | + const theme = useTheme(); |
| 72 | + const [plugins, setPlugins] = useState<PluginItem[]>([]); |
| 73 | + const [loading, setLoading] = useState(true); |
| 74 | + const [migratedNovels, setMigratedNovel] = useState<NovelInfo[]>([]); |
| 75 | + const [requiredPlugins, setRequiredPlugins] = useState<PluginItem[]>([]); |
| 76 | + const [showAlert, setShowAlert] = useState(false); |
| 77 | + useEffect(() => { |
| 78 | + fetch( |
| 79 | + "https://raw.githubusercontent.com/LNReader/lnreader-plugins/plugins/v3.0.0/.dist/plugins.min.json" |
| 80 | + ) |
| 81 | + .then((res) => res.json()) |
| 82 | + .then((plugins) => { |
| 83 | + setPlugins(plugins); |
| 84 | + setLoading(false); |
| 85 | + }); |
| 86 | + }, []); |
| 87 | + |
| 88 | + const findSuitedPlugin = (novel: OldNovelInfo) => { |
| 89 | + let novelSiteUrl; |
| 90 | + try { |
| 91 | + novelSiteUrl = new URL(novel.sourceUrl); |
| 92 | + } catch { |
| 93 | + return undefined; |
| 94 | + } |
| 95 | + const novelSiteDomain = novelSiteUrl.hostname.replace(/www\./, ""); |
| 96 | + for (const plugin of plugins) { |
| 97 | + const pluginSiteUrl = new URL(plugin.site); |
| 98 | + const pluginSiteDomain = pluginSiteUrl.hostname.replace(/www\./, ""); |
| 99 | + if (pluginSiteDomain === novelSiteDomain) { |
| 100 | + return plugin; |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + return undefined; |
| 105 | + }; |
| 106 | + |
| 107 | + const migrateNovels = (oldNovels: OldNovelInfo[]) => { |
| 108 | + const migratedNovels: NovelInfo[] = []; |
| 109 | + const requiredPlugins = new Set<PluginItem>(); |
| 110 | + for (const oldNovel of oldNovels) { |
| 111 | + const plugin = findSuitedPlugin(oldNovel); |
| 112 | + let novelUrl = oldNovel.novelUrl; |
| 113 | + if (plugin) { |
| 114 | + if (isUrlAbsolute(novelUrl)) { |
| 115 | + novelUrl = oldNovel.novelUrl.replace(plugin.site, ""); |
| 116 | + } |
| 117 | + if (plugin.id === "boxnovel") { |
| 118 | + novelUrl = "novel/" + novelUrl + "/"; |
| 119 | + } |
| 120 | + migratedNovels.push({ |
| 121 | + id: oldNovel.novelId, |
| 122 | + path: novelUrl, |
| 123 | + pluginId: plugin.id, |
| 124 | + name: oldNovel.novelName, |
| 125 | + cover: oldNovel.novelCover, |
| 126 | + summary: oldNovel.novelSummary, |
| 127 | + author: oldNovel.author, |
| 128 | + status: oldNovel.status, |
| 129 | + genres: oldNovel.genre, |
| 130 | + inLibrary: Boolean(oldNovel.followed), |
| 131 | + isLocal: false, |
| 132 | + totalPages: 0, |
| 133 | + }); |
| 134 | + requiredPlugins.add(plugin); |
| 135 | + } |
| 136 | + } |
| 137 | + |
| 138 | + setMigratedNovel(migratedNovels); |
| 139 | + setRequiredPlugins(Array.from(requiredPlugins)); |
| 140 | + }; |
| 141 | + |
| 142 | + const PluginCard = ({ plugin }: { plugin: PluginItem }) => { |
| 143 | + return ( |
| 144 | + <Button |
| 145 | + sx={{ |
| 146 | + my: 1, |
| 147 | + display: "flex", |
| 148 | + backgroundColor: theme.surfaceVariant, |
| 149 | + p: 1, |
| 150 | + borderRadius: 2, |
| 151 | + width: "100%", |
| 152 | + justifyContent: "left", |
| 153 | + textTransform: "none", |
| 154 | + }} |
| 155 | + onClick={() => { |
| 156 | + navigator.clipboard |
| 157 | + .writeText(plugin.name) |
| 158 | + .then(() => setShowAlert(true)); |
| 159 | + }} |
| 160 | + > |
| 161 | + <img src={plugin.iconUrl} alt="icon" height={30} width={30} /> |
| 162 | + <Box sx={{ ml: 2, textAlign: "left" }}> |
| 163 | + <Typography>{plugin.name}</Typography> |
| 164 | + <Typography variant="caption">{plugin.id}</Typography> |
| 165 | + </Box> |
| 166 | + <Box sx={{ flex: 1 }}></Box> |
| 167 | + <IconButton sx={{ height: "100%" }} href={plugin.site} target="_blank"> |
| 168 | + <PublicIcon /> |
| 169 | + </IconButton> |
| 170 | + </Button> |
| 171 | + ); |
| 172 | + }; |
| 173 | + |
| 174 | + useEffect(() => { |
| 175 | + if (showAlert) { |
| 176 | + setTimeout(() => setShowAlert(false), 1000); |
| 177 | + } |
| 178 | + }, [showAlert]); |
| 179 | + |
| 180 | + return ( |
| 181 | + <Layout> |
| 182 | + <Page |
| 183 | + title="Upgrade 1.x.x to 2.0.0" |
| 184 | + content={ |
| 185 | + <Box |
| 186 | + sx={{ |
| 187 | + pt: 2, |
| 188 | + }} |
| 189 | + > |
| 190 | + {showAlert ? ( |
| 191 | + <Alert |
| 192 | + variant="filled" |
| 193 | + sx={{ |
| 194 | + position: "fixed", |
| 195 | + top: 70, |
| 196 | + right: 10, |
| 197 | + zIndex: 1000, |
| 198 | + }} |
| 199 | + severity="success" |
| 200 | + > |
| 201 | + Copied name. |
| 202 | + </Alert> |
| 203 | + ) : null} |
| 204 | + <Box |
| 205 | + sx={{ |
| 206 | + display: "flex", |
| 207 | + flexDirection: { xs: "column", md: "row" }, |
| 208 | + gap: 4, |
| 209 | + }} |
| 210 | + > |
| 211 | + <Button |
| 212 | + component="label" |
| 213 | + variant="contained" |
| 214 | + tabIndex={-1} |
| 215 | + startIcon={ |
| 216 | + <UploadFileRoundedIcon sx={{ fill: theme.onSecondary }} /> |
| 217 | + } |
| 218 | + disabled={loading} |
| 219 | + sx={{ textTransform: "none" }} |
| 220 | + > |
| 221 | + Upload 1.x.x backup file |
| 222 | + <VisuallyHiddenInput |
| 223 | + type="file" |
| 224 | + onChange={(ev) => { |
| 225 | + try { |
| 226 | + setLoading(true); |
| 227 | + const file = ev.target.files?.[0]; |
| 228 | + if (file) { |
| 229 | + const reader = new FileReader(); |
| 230 | + reader.onload = (e) => { |
| 231 | + try { |
| 232 | + if (typeof e.target?.result === "string") { |
| 233 | + const oldNovels = JSON.parse(e.target.result); |
| 234 | + migrateNovels(oldNovels); |
| 235 | + } |
| 236 | + } catch (e) { |
| 237 | + alert(e); |
| 238 | + } |
| 239 | + }; |
| 240 | + reader.readAsText(file); |
| 241 | + } |
| 242 | + } finally { |
| 243 | + setLoading(false); |
| 244 | + } |
| 245 | + }} |
| 246 | + /> |
| 247 | + </Button> |
| 248 | + {!loading && migratedNovels.length ? ( |
| 249 | + <Button |
| 250 | + component="label" |
| 251 | + variant="contained" |
| 252 | + tabIndex={-1} |
| 253 | + startIcon={ |
| 254 | + <SimCardDownloadIcon sx={{ fill: theme.onTertiary }} /> |
| 255 | + } |
| 256 | + disabled={loading} |
| 257 | + sx={{ |
| 258 | + textTransform: "none", |
| 259 | + bgcolor: theme.tertiary, |
| 260 | + color: theme.onTertiary, |
| 261 | + }} |
| 262 | + onClick={() => { |
| 263 | + if (migratedNovels.length) { |
| 264 | + const migratedBlob = new Blob( |
| 265 | + [JSON.stringify(migratedNovels)], |
| 266 | + { |
| 267 | + type: "application/json", |
| 268 | + } |
| 269 | + ); |
| 270 | + const link = document.createElement("a"); |
| 271 | + link.href = window.URL.createObjectURL(migratedBlob); |
| 272 | + link.setAttribute("download", `migrated-backup.json`); |
| 273 | + document.body.appendChild(link); |
| 274 | + link.click(); |
| 275 | + link.parentNode?.removeChild(link); |
| 276 | + } |
| 277 | + }} |
| 278 | + > |
| 279 | + Download ({migratedNovels.length} novels) |
| 280 | + </Button> |
| 281 | + ) : null} |
| 282 | + </Box> |
| 283 | + <Box sx={{ mt: 2 }}> |
| 284 | + <Typography variant="h6">Required plugins</Typography> |
| 285 | + {requiredPlugins.map((plugin) => ( |
| 286 | + <PluginCard key={plugin.id} plugin={plugin} /> |
| 287 | + ))} |
| 288 | + </Box> |
| 289 | + </Box> |
| 290 | + } |
| 291 | + /> |
| 292 | + </Layout> |
| 293 | + ); |
| 294 | +} |
0 commit comments