diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index d4f32a4a..56f8d9da 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -13,6 +13,7 @@ "@tauri-apps/api": "2.0.0-rc.5", "@tauri-apps/plugin-dialog": "2.0.0-rc.1", "@tauri-apps/plugin-fs": "2.0.0-rc.2", + "@tauri-apps/plugin-http": "^2.0.0", "@tauri-apps/plugin-log": "2.0.0-rc.1", "@tauri-apps/plugin-os": "2.0.0-rc.1", "@zip.js/zip.js": "^2.7.52", diff --git a/apps/readest-app/src-tauri/Cargo.lock b/apps/readest-app/src-tauri/Cargo.lock index e7ca7be5..d6c1fa51 100644 --- a/apps/readest-app/src-tauri/Cargo.lock +++ b/apps/readest-app/src-tauri/Cargo.lock @@ -688,6 +688,34 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4934e6b7e8419148b6ef56950d277af8561060b56afd59e2aadf98b59fce6baa" +dependencies = [ + "cookie", + "idna 0.5.0", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -877,6 +905,12 @@ dependencies = [ "syn 2.0.77", ] +[[package]] +name = "data-url" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" + [[package]] name = "deranged" version = "0.3.11" @@ -1027,6 +1061,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.0" @@ -1624,6 +1667,25 @@ dependencies = [ "syn 2.0.77", ] +[[package]] +name = "h2" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.5.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1738,6 +1800,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", + "h2", "http", "http-body", "httparse", @@ -1748,6 +1811,24 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.8" @@ -1807,6 +1888,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "0.5.0" @@ -2846,6 +2937,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "ptr_meta" version = "0.1.4" @@ -2866,6 +2963,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "publicsuffix" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a8c1bda5ae1af7f99a2962e49df150414a43d62404644d98dd5c3a93d07457" +dependencies = [ + "idna 0.3.0", + "psl-types", +] + [[package]] name = "quick-xml" version = "0.32.0" @@ -2875,6 +2982,54 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c5fdde3cdae7203427dc4f0a68fe0ed09833edc525a03456b153b79828684" +dependencies = [ + "bytes", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "quinn-proto" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6" +dependencies = [ + "bytes", + "rand 0.8.5", + "ring", + "rustc-hash", + "rustls", + "slab", + "thiserror", + "tinyvec", + "tracing", +] + +[[package]] +name = "quinn-udp" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe68c2e9e1a1234e218683dbdf9f9dfcb094113c5ac2b938dfcb9bab4c4140b" +dependencies = [ + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.37" @@ -2988,6 +3143,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-http", "tauri-plugin-log", "tauri-plugin-os", ] @@ -3073,12 +3229,17 @@ checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63" dependencies = [ "base64 0.22.1", "bytes", + "cookie", + "cookie_store", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "ipnet", "js-sys", @@ -3087,11 +3248,17 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pemfile", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", + "system-configuration", "tokio", + "tokio-rustls", "tokio-util", "tower-service", "url", @@ -3099,6 +3266,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", "windows-registry", ] @@ -3126,6 +3294,21 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rkyv" version = "0.7.45" @@ -3177,6 +3360,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3199,6 +3388,46 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.23.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "415d9944693cb90382053259f89fbb077ea730ad7273047ec63b19bc9b160ba8" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e696e35370c65c9c541198af4543ccd580cf17fc25d8e05c5a242b202488c55" + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.17" @@ -3578,6 +3807,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -3631,6 +3866,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3694,6 +3935,27 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.6.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3940,6 +4202,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "tauri-plugin-http" +version = "2.0.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf6e753f6b98a326ab5e2d604973e761a7970dec0c0bcd5ba83ce7392623dfa8" +dependencies = [ + "data-url", + "http", + "regex", + "reqwest", + "schemars", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror", + "tokio", + "url", + "urlpattern", +] + [[package]] name = "tauri-plugin-log" version = "2.0.0-rc.2" @@ -4191,10 +4475,33 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "tracing", "windows-sys 0.52.0", ] +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.12" @@ -4473,6 +4780,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.2" @@ -4480,7 +4793,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", - "idna", + "idna 0.5.0", "percent-encoding", "serde", ] @@ -4727,6 +5040,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841c67bff177718f1d4dfefde8d8f0e78f9b6589319ba88312f567fc5841a958" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.33.0" @@ -5303,6 +5625,12 @@ dependencies = [ "syn 2.0.77", ] +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + [[package]] name = "zvariant" version = "4.0.0" diff --git a/apps/readest-app/src-tauri/Cargo.toml b/apps/readest-app/src-tauri/Cargo.toml index e8a7361a..3fa8e3f8 100644 --- a/apps/readest-app/src-tauri/Cargo.toml +++ b/apps/readest-app/src-tauri/Cargo.toml @@ -27,3 +27,4 @@ tauri-plugin-log = "2.0.0-rc" tauri-plugin-fs = "2.0.0-rc" tauri-plugin-dialog = "2.0.0-rc" tauri-plugin-os = "2.0.0-rc" +tauri-plugin-http = "2.0.0-rc" diff --git a/apps/readest-app/src-tauri/capabilities/default.json b/apps/readest-app/src-tauri/capabilities/default.json index 2a54361b..629dce90 100644 --- a/apps/readest-app/src-tauri/capabilities/default.json +++ b/apps/readest-app/src-tauri/capabilities/default.json @@ -34,6 +34,7 @@ ] }, "dialog:default", - "os:default" + "os:default", + "http:default" ] } \ No newline at end of file diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index 61fd3145..f6ec8d60 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) diff --git a/apps/readest-app/src/app/page.tsx b/apps/readest-app/src/app/page.tsx index 9348c78f..c239dc83 100644 --- a/apps/readest-app/src/app/page.tsx +++ b/apps/readest-app/src/app/page.tsx @@ -16,7 +16,7 @@ const LibraryPage = () => { const { envConfig } = useEnv(); const [appState, setAppState] = useState('Init'); const [libraryBooks, setLibraryBooks] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); React.useEffect(() => { if (appState !== 'Init') return; @@ -47,7 +47,7 @@ const LibraryPage = () => { appService.selectFiles('Select Books', ['epub', 'pdf']).then(async (files) => { setLoading(true); for (const file of files) { - await dav?.addBook(file, libraryBooks).then(() => {}); + await appService.importBook(file, libraryBooks); setLibraryBooks(libraryBooks); } setLoading(false); @@ -62,7 +62,7 @@ const LibraryPage = () => { - {libraryBooks.length === 0 && ( + {!loading && libraryBooks.length === 0 && (
diff --git a/apps/digest-app/src/libs/document.ts b/apps/readest-app/src/libs/document.ts similarity index 90% rename from apps/digest-app/src/libs/document.ts rename to apps/readest-app/src/libs/document.ts index e84f785c..23cf2b3f 100644 --- a/apps/digest-app/src/libs/document.ts +++ b/apps/readest-app/src/libs/document.ts @@ -41,6 +41,15 @@ export interface BookDoc { getCover(): Promise; } +export const EXTS: Record = { + EPUB: 'epub', + PDF: 'pdf', + MOBI: 'mobi', + CBZ: 'cbz', + FB2: 'fb2', + FBZ: 'fbz', +}; + export class DocumentLoader { private file: DocumentFile; @@ -86,18 +95,22 @@ export class DocumentLoader { } private isCBZ(): boolean { - return this.file.type === 'application/vnd.comicbook+zip' || this.file.name.endsWith('.cbz'); + return ( + this.file.type === 'application/vnd.comicbook+zip' || this.file.name.endsWith(`.${EXTS.CBZ}`) + ); } private isFB2(): boolean { - return this.file.type === 'application/x-fictionbook+xml' || this.file.name.endsWith('.fb2'); + return ( + this.file.type === 'application/x-fictionbook+xml' || this.file.name.endsWith(`.${EXTS.FB2}`) + ); } private isFBZ(): boolean { return ( this.file.type === 'application/x-zip-compressed-fb2' || this.file.name.endsWith('.fb2.zip') || - this.file.name.endsWith('.fbz') + this.file.name.endsWith(`.${EXTS.FBZ}`) ); } @@ -116,7 +129,7 @@ export class DocumentLoader { book = makeComicBook(loader, this.file); format = 'CBZ'; } else if (this.isFBZ()) { - const entry = entries.find((entry) => entry.filename.endsWith('.fb2')); + const entry = entries.find((entry) => entry.filename.endsWith(`.${EXTS.FB2}`)); const blob = await loader.loadBlob((entry ?? entries[0]!).filename); const { makeFB2 } = await import('foliate-js/fb2.js'); book = await makeFB2(blob); diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index 23651e58..7dec7fc1 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -1,117 +1,31 @@ -import { - exists, - mkdir, - readTextFile, - readFile, - writeTextFile, - writeFile, - readDir, - remove, - BaseDirectory, -} from '@tauri-apps/plugin-fs'; -import { - join, - appConfigDir, - appDataDir, - appCacheDir, - appLogDir, - documentDir, -} from '@tauri-apps/api/path'; import { convertFileSrc } from '@tauri-apps/api/core'; import { open, message } from '@tauri-apps/plugin-dialog'; +import { join, documentDir } from '@tauri-apps/api/path'; -import { Book } from '../types/book'; +import { Book, BookConfig, BookFormat } from '../types/book'; import { SystemSettings } from '../types/settings'; -import { AppService, BaseDir, ToastType } from '../types/system'; +import { AppService, ToastType } from '../types/system'; import { LOCAL_BOOKS_SUBDIR } from './constants'; +import { resolvePath, nativeFileSystem } from './nativeFileSystem'; +import { + getBaseFilename, + getConfigFilename, + getCoverFilename, + getDir, + getFilename, + getLibraryFilename, + INIT_BOOK_CONFIG, +} from '@/utils/book'; +import { BookDoc, DocumentLoader } from '@/libs/document'; +import { RemoteFile } from '@/utils/file'; +import { partialMD5 } from '@/utils/md5'; let BOOKS_DIR = ''; -function resolvePath( - fp: string, - base: BaseDir, -): { baseDir: number; base: BaseDir; fp: string; dir: () => Promise } { - switch (base) { - case 'Settings': - return { baseDir: BaseDirectory.AppConfig, fp, base, dir: appConfigDir }; - case 'Data': - return { baseDir: BaseDirectory.AppData, fp, base, dir: appDataDir }; - case 'Cache': - return { baseDir: BaseDirectory.AppCache, fp, base, dir: appCacheDir }; - case 'Log': - return { baseDir: BaseDirectory.AppLog, fp, base, dir: appLogDir }; - case 'Books': - return { - baseDir: BaseDirectory.Document, - fp: `${LOCAL_BOOKS_SUBDIR}/${fp}`, - base, - dir: () => new Promise((r) => r(`${BOOKS_DIR}/`)), - }; - default: - return { - baseDir: BaseDirectory.Temp, - fp, - base, - dir: () => new Promise((r) => r('')), - }; - } -} - const SETTINGS_PATH = resolvePath('settings.json', 'Settings'); export const nativeAppService: AppService = { - fs: { - async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') { - const { fp, baseDir } = resolvePath(path, base); - - return mode === 'text' - ? (readTextFile(fp, base && { baseDir }) as Promise) - : ((await readFile(fp, base && { baseDir })).buffer as ArrayBuffer); - }, - async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer) { - const { fp, baseDir } = resolvePath(path, base); - - return typeof content === 'string' - ? writeTextFile(fp, content, base && { baseDir }) - : writeFile(fp, new Uint8Array(content), base && { baseDir }); - }, - async removeFile(path: string, base: BaseDir) { - const { fp, baseDir } = resolvePath(path, base); - - return remove(fp, base && { baseDir }); - }, - async createDir(path: string, base: BaseDir, recursive = false) { - const { fp, baseDir } = resolvePath(path, base); - - await mkdir(fp, base && { baseDir, recursive }); - }, - async removeDir(path: string, base: BaseDir, recursive = false) { - const { fp, baseDir } = resolvePath(path, base); - - await remove(fp, base && { baseDir, recursive }); - }, - async readDir(path: string, base: BaseDir) { - const { fp, baseDir } = resolvePath(path, base); - - const list = await readDir(fp, base && { baseDir }); - return list.map((entity) => { - return { - path: entity.name, - isDir: entity.isDirectory, - }; - }); - }, - async exists(path: string, base: BaseDir) { - const { fp, baseDir } = resolvePath(path, base); - - try { - const res = await exists(fp, base && { baseDir }); - return res; - } catch { - return false; - } - }, - }, + fs: nativeFileSystem, loadSettings: async () => { let settings: SystemSettings; const { fp, base } = SETTINGS_PATH; @@ -172,22 +86,114 @@ export const nativeAppService: AppService = { showMessage: async (msg: string, kind: ToastType = 'info', title?: string, okLabel?: string) => { await message(msg, { kind, title, okLabel }); }, + importBook: async ( + file: string | File, + books: Book[], + overwrite: boolean = false, + ): Promise => { + try { + let loadedBook: BookDoc; + let format: BookFormat; + let filename: string; + let fileobj: File; + + try { + if (typeof file === 'string') { + filename = file; + fileobj = await new RemoteFile(nativeAppService.fs.getURL(file), file).open(); + } else { + filename = file.name; + fileobj = file; + } + ({ book: loadedBook, format } = await new DocumentLoader(fileobj).open()); + if (!loadedBook.metadata.title) { + loadedBook.metadata.title = getBaseFilename(filename); + } + } catch (error) { + console.error(error); + throw new Error(`Failed to open the book: ${(error as Error).message || error}`); + } + + const hash = await partialMD5(fileobj); + const existingBook = books.filter((b) => b.hash === hash)[0]; + if (existingBook) { + if (existingBook.isRemoved) { + delete existingBook.isRemoved; + } + existingBook.lastUpdated = Date.now(); + } + + const book: Book = { + hash, + format, + title: loadedBook.metadata.title, + author: loadedBook.metadata.author, + lastUpdated: Date.now(), + }; + book.coverImageUrl = nativeAppService.getCoverImageUrl(book); + + if (!(await nativeAppService.fs.exists(getDir(book), 'Books'))) { + await nativeAppService.fs.createDir(getDir(book), 'Books'); + } + if (!(await nativeAppService.fs.exists(getFilename(book), 'Books')) || overwrite) { + if (typeof file === 'string') { + await nativeAppService.fs.copyFile(file, getFilename(book), 'Books'); + } else { + await nativeAppService.fs.writeFile(getFilename(book), 'Books', await file.arrayBuffer()); + } + } + if (!(await nativeAppService.fs.exists(getCoverFilename(book), 'Books')) || overwrite) { + const cover = await loadedBook.getCover(); + if (cover) { + await nativeAppService.fs.writeFile( + getCoverFilename(book), + 'Books', + await cover.arrayBuffer(), + ); + } + } + // Never overwrite the config file only when it's not existed + if (!existingBook) { + await nativeAppService.saveBookConfig(book, INIT_BOOK_CONFIG); + books.splice(0, 0, book); + } + } catch (error) { + throw error; + } + + return books; + }, + loadBookConfig: async (book: Book): Promise => { + try { + const str = await nativeAppService.fs.readFile(getConfigFilename(book), 'Books', 'text'); + return JSON.parse(str as string); + } catch { + return INIT_BOOK_CONFIG; + } + }, + saveBookConfig: async (book: Book, config: BookConfig) => { + await nativeAppService.fs.writeFile(getConfigFilename(book), 'Books', JSON.stringify(config)); + }, loadLibraryBooks: async () => { let books: Book[] = []; + const libraryFilename = getLibraryFilename(); try { - const txt = await nativeAppService.fs.readFile('books.json', 'Books', 'text'); + const txt = await nativeAppService.fs.readFile(libraryFilename, 'Books', 'text'); books = JSON.parse(txt as string); } catch { - await nativeAppService.fs.writeFile('books.json', 'Books', '[]'); + await nativeAppService.fs.writeFile(libraryFilename, 'Books', '[]'); } books.forEach((book) => { - book.coverImageUrl = nativeAppService.generateCoverUrl(book); + book.coverImageUrl = nativeAppService.getCoverImageUrl(book); }); return books; }, - generateCoverUrl: (book: Book) => { - return convertFileSrc(`${BOOKS_DIR}/${book.hash}/cover.png`); + saveLibraryBooks: async (books: Book[]) => { + await nativeAppService.fs.writeFile(getLibraryFilename(), 'Books', JSON.stringify(books)); + }, + getCoverImageUrl: (book: Book) => { + return convertFileSrc(`${BOOKS_DIR}/${getCoverFilename(book)}`); }, }; diff --git a/apps/readest-app/src/services/nativeFileSystem.ts b/apps/readest-app/src/services/nativeFileSystem.ts new file mode 100644 index 00000000..5920bddf --- /dev/null +++ b/apps/readest-app/src/services/nativeFileSystem.ts @@ -0,0 +1,104 @@ +import { + exists, + mkdir, + readTextFile, + readFile, + writeTextFile, + writeFile, + readDir, + remove, + BaseDirectory, + copyFile, +} from '@tauri-apps/plugin-fs'; +import { convertFileSrc } from '@tauri-apps/api/core'; + +import { FileSystem, BaseDir } from '../types/system'; +import { LOCAL_BOOKS_SUBDIR } from './constants'; + +export const resolvePath = ( + fp: string, + base: BaseDir, +): { baseDir: number; base: BaseDir; fp: string } => { + switch (base) { + case 'Settings': + return { baseDir: BaseDirectory.AppConfig, fp, base }; + case 'Data': + return { baseDir: BaseDirectory.AppData, fp, base }; + case 'Cache': + return { baseDir: BaseDirectory.AppCache, fp, base }; + case 'Log': + return { baseDir: BaseDirectory.AppLog, fp, base }; + case 'Books': + return { + baseDir: BaseDirectory.Document, + fp: `${LOCAL_BOOKS_SUBDIR}/${fp}`, + base, + }; + default: + return { + baseDir: BaseDirectory.Temp, + fp, + base, + }; + } +}; + +export const nativeFileSystem: FileSystem = { + getURL(path: string) { + return convertFileSrc(path); + }, + async copyFile(srcPath: string, dstPath: string, base: BaseDir) { + const { fp, baseDir } = resolvePath(dstPath, base); + await copyFile(srcPath, fp, base && { toPathBaseDir: baseDir }); + }, + async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') { + const { fp, baseDir } = resolvePath(path, base); + + return mode === 'text' + ? (readTextFile(fp, base && { baseDir }) as Promise) + : ((await readFile(fp, base && { baseDir })).buffer as ArrayBuffer); + }, + async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer) { + const { fp, baseDir } = resolvePath(path, base); + + return typeof content === 'string' + ? writeTextFile(fp, content, base && { baseDir }) + : writeFile(fp, new Uint8Array(content), base && { baseDir }); + }, + async removeFile(path: string, base: BaseDir) { + const { fp, baseDir } = resolvePath(path, base); + + return remove(fp, base && { baseDir }); + }, + async createDir(path: string, base: BaseDir, recursive = false) { + const { fp, baseDir } = resolvePath(path, base); + + await mkdir(fp, base && { baseDir, recursive }); + }, + async removeDir(path: string, base: BaseDir, recursive = false) { + const { fp, baseDir } = resolvePath(path, base); + + await remove(fp, base && { baseDir, recursive }); + }, + async readDir(path: string, base: BaseDir) { + const { fp, baseDir } = resolvePath(path, base); + + const list = await readDir(fp, base && { baseDir }); + return list.map((entity) => { + return { + path: entity.name, + isDir: entity.isDirectory, + }; + }); + }, + async exists(path: string, base: BaseDir) { + const { fp, baseDir } = resolvePath(path, base); + + try { + const res = await exists(fp, base && { baseDir }); + return res; + } catch { + return false; + } + }, +}; diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index 460df23e..a812105b 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -1,10 +1,12 @@ import { SystemSettings } from './settings'; -import { Book } from './book'; +import { Book, BookConfig } from './book'; export type BaseDir = 'Books' | 'Settings' | 'Data' | 'Log' | 'Cache' | 'None'; export type ToastType = 'info' | 'warning' | 'error'; export interface FileSystem { + getURL(path: string): string; + copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise; readFile(path: string, base: BaseDir, mode: 'text' | 'binary'): Promise; writeFile(path: string, base: BaseDir, content: string | ArrayBuffer): Promise; removeFile(path: string, base: BaseDir): Promise; @@ -23,6 +25,10 @@ export interface AppService { selectFiles(name: string, extensions: string[]): Promise; showMessage(msg: string, kind?: ToastType, title?: string, okLabel?: string): Promise; + importBook(file: string | File, books: Book[], overwrite?: boolean): Promise; + loadBookConfig(book: Book): Promise; + saveBookConfig(book: Book, config: BookConfig): Promise; loadLibraryBooks(): Promise; - generateCoverUrl(book: Book): string; + saveLibraryBooks(books: Book[]): Promise; + getCoverImageUrl(book: Book): string; } diff --git a/apps/readest-app/src/utils/book.ts b/apps/readest-app/src/utils/book.ts new file mode 100644 index 00000000..409d400e --- /dev/null +++ b/apps/readest-app/src/utils/book.ts @@ -0,0 +1,33 @@ +import { EXTS } from '@/libs/document'; +import { Book, BookConfig } from '@/types/book'; + +export const getDir = (book: Book) => { + return `${book.hash}`; +}; +export const getLibraryFilename = () => { + return 'library.json'; +}; +export const getFilename = (book: Book) => { + return `${book.hash}/${book.title}.${EXTS[book.format]}`; +}; +export const getCoverFilename = (book: Book) => { + return `${book.hash}/cover.png`; +}; +export const getConfigFilename = (book: Book) => { + return `${book.hash}/config.json`; +}; +export const isBookFile = (filename: string) => { + return Object.values(EXTS).includes(filename.split('.').pop()!); +}; +export const getBaseFilename = (filename: string) => { + const normalizedPath = filename.replace(/\\/g, '/'); + const baseName = normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || ''; + return baseName; +}; +export const INIT_BOOK_CONFIG: BookConfig = { + lastUpdated: 0, + remoteProgress: 0, + localProgress: 0, + bookmarks: [], + annotations: [], +}; diff --git a/apps/readest-app/src/utils/file.ts b/apps/readest-app/src/utils/file.ts new file mode 100644 index 00000000..4e648dab --- /dev/null +++ b/apps/readest-app/src/utils/file.ts @@ -0,0 +1,188 @@ +class RemoteBlobSlice extends Blob { + #dataPromise: Promise; + #type: string; + + constructor(dataPromise: Promise, type: string) { + super(); + this.#dataPromise = dataPromise; + this.#type = type; + } + + override async arrayBuffer() { + const data = await this.#dataPromise; + return data; + } + + override async text() { + const data = await this.#dataPromise; + return new TextDecoder().decode(data); + } + + override stream() { + return new ReadableStream({ + start: async (controller) => { + const data = await this.#dataPromise; + const reader = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(data)); + controller.close(); + }, + }).getReader(); + const pump = () => + reader.read().then(({ done, value }): Promise => { + if (done) { + controller.close(); + return Promise.resolve(); + } + controller.enqueue(value); + return pump(); + }); + return pump(); + }, + }); + } + + override get type() { + return this.#type; + } +} + +export class RemoteFile extends File { + url: string; + #name: string; + #lastModified: number; + #size: number = -1; + #type: string = ''; + #cache: Map = new Map(); // LRU cache + #order: number[] = []; + + static MAX_CACHE_CHUNK_SIZE = 1024 * 128; + static MAX_CACHE_ITEMS_SIZE: number = 10; + + constructor(url: string, name = 'remote-file', type = '', lastModified = Date.now()) { + super([], name, { type: type, lastModified }); + this.url = url; + this.#name = name; + this.#type = type; + this.#lastModified = lastModified; + } + + override get name() { + return this.#name; + } + + override get type() { + return this.#type; + } + + override get size() { + return this.#size; + } + + override get lastModified() { + return this.#lastModified; + } + + async open() { + const response = await fetch(this.url, { method: 'HEAD' }); + if (!response.ok) { + throw new Error(`Failed to fetch file size: ${response.status}`); + } + this.#size = Number(response.headers.get('content-length')); + this.#type = response.headers.get('content-type') || ''; + return this; + } + + async fetchRangePart(start: number, end: number) { + start = Math.max(0, start); + end = Math.min(this.size - 1, end); + // console.log(`Fetching range: ${start}-${end}, size: ${end - start + 1}`); + const response = await fetch(this.url, { headers: { Range: `bytes=${start}-${end}` } }); + if (!response.ok) { + throw new Error(`Failed to fetch range: ${response.status}`); + } + return response.arrayBuffer(); + } + + async fetchRange(start: number, end: number): Promise { + const rangeSize = end - start + 1; + const MAX_RANGE_LEN = 1024 * 1000; + + if (rangeSize > MAX_RANGE_LEN) { + const buffers: ArrayBuffer[] = []; + for (let currentStart = start; currentStart <= end; currentStart += MAX_RANGE_LEN) { + const currentEnd = Math.min(currentStart + MAX_RANGE_LEN - 1, end); + buffers.push(await this.fetchRangePart(currentStart, currentEnd)); + } + const totalSize = buffers.reduce((sum, buffer) => sum + buffer.byteLength, 0); + const combinedBuffer = new Uint8Array(totalSize); + let offset = 0; + for (const buffer of buffers) { + combinedBuffer.set(new Uint8Array(buffer), offset); + offset += buffer.byteLength; + } + return combinedBuffer.buffer; + } else if (rangeSize > RemoteFile.MAX_CACHE_CHUNK_SIZE) { + return this.fetchRangePart(start, end); + } else { + let cachedChunkStart = Array.from(this.#cache.keys()).find((chunkStart) => { + const buffer = this.#cache.get(chunkStart)!; + const bufferSize = buffer.byteLength; + return start >= chunkStart && end <= chunkStart + bufferSize; + }); + if (cachedChunkStart !== undefined) { + this.#updateAccessOrder(cachedChunkStart); + const buffer = this.#cache.get(cachedChunkStart)!; + const offset = start - cachedChunkStart; + return buffer.slice(offset, offset + rangeSize); + } + cachedChunkStart = await this.#fetchAndCacheChunk(start, end); + const buffer = this.#cache.get(cachedChunkStart)!; + const offset = start - cachedChunkStart; + return buffer.slice(offset, offset + rangeSize); + } + } + + async #fetchAndCacheChunk(start: number, end: number): Promise { + const chunkStart = Math.max(0, start - 1024); + const chunkEnd = Math.max(end, start + RemoteFile.MAX_CACHE_CHUNK_SIZE - 1024 - 1); + this.#cache.set(chunkStart, await this.fetchRangePart(chunkStart, chunkEnd)); + this.#updateAccessOrder(chunkStart); + this.#ensureCacheSize(); + return chunkStart; + } + + #updateAccessOrder(chunkStart: number) { + const index = this.#order.indexOf(chunkStart); + if (index > -1) { + this.#order.splice(index, 1); + } + this.#order.unshift(chunkStart); + } + + #ensureCacheSize() { + while (this.#cache.size > RemoteFile.MAX_CACHE_ITEMS_SIZE) { + const oldestKey = this.#order.pop(); + if (oldestKey !== undefined) { + this.#cache.delete(oldestKey); + } + } + } + + override slice(start = 0, end = this.size, contentType = this.type): Blob { + // console.log(`Slicing: ${start}-${end}, size: ${end - start}`); + const dataPromise = this.fetchRange(start, end - 1); + + return new RemoteBlobSlice(dataPromise, contentType); + } + + override async text() { + const blob = this.slice(0, this.size); + return blob.text(); + } + + override async arrayBuffer() { + const blob = this.slice(0, this.size); + return blob.arrayBuffer(); + } +} diff --git a/apps/readest-app/src/utils/md5.ts b/apps/readest-app/src/utils/md5.ts new file mode 100644 index 00000000..6aaa0eda --- /dev/null +++ b/apps/readest-app/src/utils/md5.ts @@ -0,0 +1,22 @@ +import { md5 } from 'js-md5'; + +export async function partialMD5(file: File): Promise { + const step = 1024; + const size = 1024; + const hasher = md5.create(); + + for (let i = -1; i <= 10; i++) { + const start = Math.min(file.size, step << (2 * i)); + const end = Math.min(start + size, file.size); + + if (start >= file.size) break; + + const blobSlice = file.slice(start, end); + const arrayBuffer = await blobSlice.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + + hasher.update(uint8Array); + } + + return hasher.hex(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e50894a8..393ab1ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@tauri-apps/plugin-fs': specifier: 2.0.0-rc.2 version: 2.0.0-rc.2 + '@tauri-apps/plugin-http': + specifier: ^2.0.0 + version: 2.0.0 '@tauri-apps/plugin-log': specifier: 2.0.0-rc.1 version: 2.0.0-rc.1 @@ -291,6 +294,9 @@ packages: '@tauri-apps/api@2.0.0-rc.5': resolution: {integrity: sha512-JWs69pE9NsQdcqTpEVBepAZ08+jgQWuthIiTiaWRq/YlCLgnqq0KfC9sDem55uJ0YpgsytZuyj+m6b6q8oiD2g==} + '@tauri-apps/api@2.0.2': + resolution: {integrity: sha512-3wSwmG+1kr6WrgAFKK5ijkNFPp8TT3FLj3YHUb5EwMO+3FxX4uWlfSWkeeBy+Kc1RsKzugtYLuuya+98Flj+3w==} + '@tauri-apps/cli-darwin-arm64@2.0.0-rc.16': resolution: {integrity: sha512-lISZU4gG0c9PbY7h/j/gW7nJLxZEygNBrYEET6zN8R99Znf5rSO+CfjenaMcJUUj6yTAd8gzdakRpLqNSAWegA==} engines: {node: '>= 10'} @@ -366,6 +372,9 @@ packages: '@tauri-apps/plugin-fs@2.0.0-rc.2': resolution: {integrity: sha512-TFjCfso3tN4b5s2EBjqP8N2gYrPh93Ds3VNKj8pCXv4wbvnItyfG0aHO0haUsedBOHQryDwv9vDAdPX6/T0a+g==} + '@tauri-apps/plugin-http@2.0.0': + resolution: {integrity: sha512-UfKAICL25ayluV/SjiEQujz8q/2uyAzp3u9uaHFkaIyKS5usBL8DoqSwi4eKz2mEjkbxTwldhDEXG4CEfTE0JQ==} + '@tauri-apps/plugin-log@2.0.0-rc.1': resolution: {integrity: sha512-+Tz0zo4FDtC/5j7neeIq5ievgKbUXBV2+X5HtbaR8ZZ2bcksCp8UqeHd6cyyN+FSk4qaU01LIGkuExtxk1h/FA==} @@ -2136,6 +2145,8 @@ snapshots: '@tauri-apps/api@2.0.0-rc.5': {} + '@tauri-apps/api@2.0.2': {} + '@tauri-apps/cli-darwin-arm64@2.0.0-rc.16': optional: true @@ -2187,6 +2198,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.0.0-rc.5 + '@tauri-apps/plugin-http@2.0.0': + dependencies: + '@tauri-apps/api': 2.0.2 + '@tauri-apps/plugin-log@2.0.0-rc.1': dependencies: '@tauri-apps/api': 2.0.0-rc.5