Add support for book import

This commit is contained in:
chrox
2024-10-07 10:58:31 +02:00
parent 01ad5dbb14
commit 06fe9144fe
14 changed files with 838 additions and 119 deletions
+1
View File
@@ -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",
+329 -1
View File
@@ -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"
+1
View File
@@ -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"
@@ -34,6 +34,7 @@
]
},
"dialog:default",
"os:default"
"os:default",
"http:default"
]
}
+1
View File
@@ -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())
+3 -3
View File
@@ -16,7 +16,7 @@ const LibraryPage = () => {
const { envConfig } = useEnv();
const [appState, setAppState] = useState<AppState>('Init');
const [libraryBooks, setLibraryBooks] = useState<Book[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(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 = () => {
<Spinner loading={loading} />
<Bookshelf libraryBooks={libraryBooks} onImportBooks={handleImportBooks} />
</div>
{libraryBooks.length === 0 && (
{!loading && libraryBooks.length === 0 && (
<div className='hero min-h-screen'>
<div className='hero-content text-neutral-content text-center'>
<div className='max-w-md'>
@@ -41,6 +41,15 @@ export interface BookDoc {
getCover(): Promise<Blob | null>;
}
export const EXTS: Record<BookFormat, string> = {
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);
+114 -108
View File
@@ -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<string> } {
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<string>)
: ((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<Book[]> => {
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<BookConfig> => {
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)}`);
},
};
@@ -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<string>)
: ((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;
}
},
};
+8 -2
View File
@@ -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<void>;
readFile(path: string, base: BaseDir, mode: 'text' | 'binary'): Promise<string | ArrayBuffer>;
writeFile(path: string, base: BaseDir, content: string | ArrayBuffer): Promise<void>;
removeFile(path: string, base: BaseDir): Promise<void>;
@@ -23,6 +25,10 @@ export interface AppService {
selectFiles(name: string, extensions: string[]): Promise<string[]>;
showMessage(msg: string, kind?: ToastType, title?: string, okLabel?: string): Promise<void>;
importBook(file: string | File, books: Book[], overwrite?: boolean): Promise<Book[]>;
loadBookConfig(book: Book): Promise<BookConfig>;
saveBookConfig(book: Book, config: BookConfig): Promise<void>;
loadLibraryBooks(): Promise<Book[]>;
generateCoverUrl(book: Book): string;
saveLibraryBooks(books: Book[]): Promise<void>;
getCoverImageUrl(book: Book): string;
}
+33
View File
@@ -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: [],
};
+188
View File
@@ -0,0 +1,188 @@
class RemoteBlobSlice extends Blob {
#dataPromise: Promise<ArrayBuffer>;
#type: string;
constructor(dataPromise: Promise<ArrayBuffer>, 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<void> => {
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<number, ArrayBuffer> = 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<ArrayBuffer> {
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<number> {
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();
}
}
+22
View File
@@ -0,0 +1,22 @@
import { md5 } from 'js-md5';
export async function partialMD5(file: File): Promise<string> {
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();
}
+15
View File
@@ -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