sync: add download/upload progress indicator (#277)

* sync: add download/upload progress indicator

* chore: apt update before installing system dependencies
This commit is contained in:
Huang Xin
2025-02-01 12:17:21 +01:00
committed by GitHub
parent b6c8bfb52b
commit 44885f2901
16 changed files with 607 additions and 105 deletions
+4 -2
View File
@@ -9,7 +9,7 @@ jobs:
rust_lint:
runs-on: ubuntu-latest
env:
RUSTFLAGS: "-C target-cpu=skylake"
RUSTFLAGS: '-C target-cpu=skylake'
steps:
- uses: actions/checkout@v4
with:
@@ -20,7 +20,9 @@ jobs:
toolchain: stable
override: true
- name: Install system dependencies
run: sudo apt-get install -y libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev
run: |
sudo apt-get update
sudo apt-get install -y libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev
- name: Format
working-directory: apps/readest-app/src-tauri
run: cargo fmt --check
Generated
+17
View File
@@ -7,9 +7,12 @@ name = "Readest"
version = "0.2.1"
dependencies = [
"cocoa 0.25.0",
"futures-util",
"log",
"objc",
"rand 0.8.5",
"read-progress-stream",
"reqwest",
"serde",
"serde_json",
"tauri",
@@ -29,6 +32,9 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"thiserror 2.0.11",
"tokio",
"tokio-util",
]
[[package]]
@@ -3941,6 +3947,17 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "read-progress-stream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6435842fc2fea44b528719eb8c32203bbc1bb2f5b619fbe0c0a3d8350fd8d2a8"
dependencies = [
"bytes",
"futures",
"pin-project-lite",
]
[[package]]
name = "redox_syscall"
version = "0.5.8"
+9
View File
@@ -21,6 +21,15 @@ tauri-build = { version = "2.0.3", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
thiserror = "2"
tokio = { version = "1", features = ["fs"] }
tokio-util = { version = "0.7", features = ["codec"] }
futures-util = "0.3"
read-progress-stream = "1.0.0"
reqwest = { version = "0.12", default-features = false, features = [
"json",
"stream",
] }
# FIXME: remove the devtools feature in production
tauri = { version = "2.1.1", features = [ "protocol-asset", "devtools"] }
tauri-build = "2.0.3"
+8 -1
View File
@@ -11,6 +11,9 @@ mod menu;
#[cfg(target_os = "macos")]
mod traffic_light_plugin;
mod transfer_file;
use transfer_file::{download_file, upload_file};
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
@@ -80,7 +83,11 @@ pub fn run() {
let builder = tauri::Builder::default()
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_oauth::init())
.invoke_handler(tauri::generate_handler![start_server])
.invoke_handler(tauri::generate_handler![
start_server,
download_file,
upload_file
])
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_http::init())
@@ -0,0 +1,203 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! Upload files from disk to a remote server over HTTP.
//!
//! Download files from a remote HTTP server to disk.
use futures_util::TryStreamExt;
use serde::{ser::Serializer, Serialize};
use tauri::{command, ipc::Channel};
use tokio::{
fs::File,
io::{AsyncWriteExt, BufWriter},
};
use tokio_util::codec::{BytesCodec, FramedRead};
use read_progress_stream::ReadProgressStream;
use std::collections::HashMap;
use std::time::Instant;
type Result<T> = std::result::Result<T, Error>;
// The TransferStats struct tracks both transfer speed and cumulative transfer progress.
pub struct TransferStats {
accumulated_chunk_len: usize, // Total length of chunks transferred in the current period
accumulated_time: u128, // Total time taken for the transfers in the current period
pub transfer_speed: u64, // Calculated transfer speed in bytes per second
pub total_transferred: u64, // Cumulative total of all transferred data
start_time: Instant, // Time when the current period started
granularity: u32, // Time period (in milliseconds) over which the transfer speed is calculated
}
impl TransferStats {
// Initializes a new TransferStats instance with the specified granularity.
pub fn start(granularity: u32) -> Self {
Self {
accumulated_chunk_len: 0,
accumulated_time: 0,
transfer_speed: 0,
total_transferred: 0,
start_time: Instant::now(),
granularity,
}
}
// Records the transfer of a data chunk and updates both transfer speed and total progress.
pub fn record_chunk_transfer(&mut self, chunk_len: usize) {
let now = Instant::now();
let it_took = now.duration_since(self.start_time).as_millis();
self.accumulated_chunk_len += chunk_len;
self.total_transferred += chunk_len as u64;
self.accumulated_time += it_took;
// Calculate transfer speed if accumulated time exceeds granularity.
if self.accumulated_time >= self.granularity as u128 {
self.transfer_speed =
(self.accumulated_chunk_len as u128 / self.accumulated_time * 1024) as u64;
self.accumulated_chunk_len = 0;
self.accumulated_time = 0;
}
// Reset the start time for the next period.
self.start_time = now;
}
}
// Provides a default implementation for TransferStats with a granularity of 500 milliseconds.
impl Default for TransferStats {
fn default() -> Self {
Self::start(500) // Default granularity is 500 ms
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Request(#[from] reqwest::Error),
#[error("{0}")]
ContentLength(String),
#[error("request failed with status code {0}: {1}")]
HttpErrorCode(u16, String),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProgressPayload {
progress: u64,
total: u64,
transfer_speed: u64,
}
#[command]
pub async fn download_file(
url: &str,
file_path: &str,
headers: HashMap<String, String>,
body: Option<String>,
on_progress: Channel<ProgressPayload>,
) -> Result<()> {
let client = reqwest::Client::new();
let mut request = if let Some(body) = body {
client.post(url).body(body)
} else {
client.get(url)
};
// Loop trought the headers keys and values
// and add them to the request object.
for (key, value) in headers {
request = request.header(&key, value);
}
let response = request.send().await?;
if !response.status().is_success() {
return Err(Error::HttpErrorCode(
response.status().as_u16(),
response.text().await.unwrap_or_default(),
));
}
let total = response.content_length().unwrap_or(0);
let mut file = BufWriter::new(File::create(file_path).await?);
let mut stream = response.bytes_stream();
let mut stats = TransferStats::default();
while let Some(chunk) = stream.try_next().await? {
file.write_all(&chunk).await?;
stats.record_chunk_transfer(chunk.len());
let _ = on_progress.send(ProgressPayload {
progress: stats.total_transferred,
total,
transfer_speed: stats.transfer_speed,
});
}
file.flush().await?;
Ok(())
}
#[command]
pub async fn upload_file(
url: &str,
file_path: &str,
method: &str,
headers: HashMap<String, String>,
on_progress: Channel<ProgressPayload>,
) -> Result<String> {
let file = File::open(file_path).await?;
let file_len = file.metadata().await.unwrap().len();
let client = reqwest::Client::new();
let mut request = match method.to_uppercase().as_str() {
"POST" => client.post(url),
"PUT" => client.put(url),
_ => return Err(Error::ContentLength("Invalid HTTP method".into())),
};
request = request
.header(reqwest::header::CONTENT_LENGTH, file_len)
.body(file_to_body(on_progress.clone(), file, file_len));
for (key, value) in headers {
request = request.header(&key, value);
}
let response = request.send().await?;
if response.status().is_success() {
response.text().await.map_err(Into::into)
} else {
Err(Error::HttpErrorCode(
response.status().as_u16(),
response.text().await.unwrap_or_default(),
))
}
}
fn file_to_body(channel: Channel<ProgressPayload>, file: File, file_len: u64) -> reqwest::Body {
let stream = FramedRead::new(file, BytesCodec::new()).map_ok(|r| r.freeze());
let mut stats = TransferStats::default();
reqwest::Body::wrap_stream(ReadProgressStream::new(
stream,
Box::new(move |progress_chunk, _progress_total| {
stats.record_chunk_transfer(progress_chunk as usize);
let _ = channel.send(ProgressPayload {
progress: stats.total_transferred,
total: file_len,
transfer_speed: stats.transfer_speed,
});
}),
))
}
@@ -3,16 +3,17 @@ import Image from 'next/image';
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
import { CiCircleMore } from 'react-icons/ci';
import { LiaCloudUploadAltSolid, LiaCloudDownloadAltSolid } from 'react-icons/lia';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { isWebAppPlatform } from '@/services/environment';
import ReadingProgress from './ReadingProgress';
import { Book } from '@/types/book';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import ReadingProgress from './ReadingProgress';
interface BookItemProps {
book: Book;
isSelectMode: boolean;
selectedBooks: string[];
clickedBookHash: string | null;
transferProgress: number | null;
handleBookClick: (book: Book) => void;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
@@ -25,6 +26,7 @@ const BookItem: React.FC<BookItemProps> = ({
isSelectMode,
selectedBooks,
clickedBookHash,
transferProgress,
handleBookClick,
handleBookUpload,
handleBookDownload,
@@ -78,7 +80,7 @@ const BookItem: React.FC<BookItemProps> = ({
)}
</div>
</div>
<div className={clsx('flex w-full p-0 pt-2', isWebAppPlatform() ? 'flex-col' : 'flex-col')}>
<div className={clsx('flex w-full flex-col p-0 pt-2')}>
<div className='min-w-0 flex-1'>
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-[0.6em] text-xs font-semibold'>
{book.title}
@@ -89,21 +91,37 @@ const BookItem: React.FC<BookItemProps> = ({
>
{book.progress && <ReadingProgress book={book} />}
<div className='flex items-center gap-x-1'>
<button
className='show-detail-button opacity-0 group-hover:opacity-100'
onClick={() => {
if (!book.uploadedAt) {
handleBookUpload(book);
} else if (!book.downloadedAt) {
handleBookDownload(book);
}
}}
>
{!book.uploadedAt && <LiaCloudUploadAltSolid size={iconSize15} />}
{book.uploadedAt && !book.downloadedAt && (
<LiaCloudDownloadAltSolid size={iconSize15} />
)}
</button>
{transferProgress !== null ? (
transferProgress === 100 ? null : (
<div
className='radial-progress opacity-0 group-hover:opacity-100'
style={
{
'--value': transferProgress,
'--size': `${iconSize15}px`,
'--thickness': '2px',
} as React.CSSProperties
}
role='progressbar'
></div>
)
) : (
<button
className='show-detail-button opacity-0 group-hover:opacity-100'
onClick={() => {
if (!book.uploadedAt) {
handleBookUpload(book);
} else if (!book.downloadedAt) {
handleBookDownload(book);
}
}}
>
{!book.uploadedAt && <LiaCloudUploadAltSolid size={iconSize15} />}
{book.uploadedAt && !book.downloadedAt && (
<LiaCloudDownloadAltSolid size={iconSize15} />
)}
</button>
)}
<button
className='show-detail-button opacity-0 group-hover:opacity-100'
onClick={() => showBookDetailsModal(book)}
@@ -56,6 +56,8 @@ interface BookshelfProps {
handleImportBooks: () => void;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
handleBookDelete: (book: Book) => void;
booksTransferProgress: { [key: string]: number | null };
}
const Bookshelf: React.FC<BookshelfProps> = ({
@@ -64,6 +66,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleImportBooks,
handleBookUpload,
handleBookDownload,
handleBookDelete,
booksTransferProgress,
}) => {
const _ = useTranslation();
const router = useRouter();
@@ -81,14 +85,12 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const makeBookAvailable = async (book: Book) => {
if (book.uploadedAt && !book.downloadedAt) {
setLoading(true);
let available = false;
try {
await appService?.downloadBook(book);
await handleBookDownload(book);
updateBook(envConfig, book);
available = true;
} finally {
setLoading(false);
return available;
}
}
@@ -156,11 +158,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({
navigateToReader(router, selectedBooks);
};
const confirmDelete = () => {
const confirmDelete = async () => {
for (const selectedBook of selectedBooks) {
const book = libraryBooks.find((b) => b.hash === selectedBook);
if (book) {
updateBook(envConfig, book, true);
await handleBookDelete(book);
}
}
setSelectedBooks([]);
@@ -206,7 +208,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const deleteBookMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
updateBook(envConfig, book, true);
await handleBookDelete(book);
},
});
const menu = await Menu.new();
@@ -238,6 +240,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
bookContextMenuHandler={bookContextMenuHandler}
transferProgress={booksTransferProgress[item.hash] || null}
/>
) : (
<GroupItem group={item} />
+45 -9
View File
@@ -30,6 +30,8 @@ import { Toast } from '@/components/Toast';
import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader';
import Bookshelf from './components/Bookshelf';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
const LibraryPage = () => {
const router = useRouter();
@@ -49,6 +51,9 @@ const LibraryPage = () => {
const isInitiating = useRef(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
const [isSelectMode, setIsSelectMode] = useState(false);
const [booksTransferProgress, setBooksTransferProgress] = useState<{
[key: string]: number | null;
}>({});
const demoBooks = useDemoBooks();
const containerRef = useRef<HTMLDivElement>(null);
@@ -212,10 +217,20 @@ const LibraryPage = () => {
});
};
const updateBookTransferProgress = throttle((bookHash: string, progress: ProgressPayload) => {
if (progress.total === 0) return;
const progressPct = (progress.progress / progress.total) * 100;
setBooksTransferProgress((prev) => ({
...prev,
[bookHash]: progressPct,
}));
}, 500);
const handleBookUpload = async (book: Book) => {
setLoading(true);
try {
await appService?.uploadBook(book);
await appService?.uploadBook(book, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
pushLibrary();
eventDispatcher.dispatch('toast', {
@@ -232,16 +247,15 @@ const LibraryPage = () => {
title: book.title,
}),
});
} finally {
setLoading(false);
}
};
const handleBookDownload = async (book: Book) => {
setLoading(true);
try {
await appService?.downloadBook(book);
updateBook(envConfig, book);
await appService?.downloadBook(book, false, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
@@ -256,8 +270,28 @@ const LibraryPage = () => {
}),
type: 'error',
});
} finally {
setLoading(false);
}
};
const handleBookDelete = async (book: Book) => {
try {
await appService?.deleteBook(book, !!book.uploadedAt);
await updateBook(envConfig, book);
pushLibrary();
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book deleted: {{title}}', {
title: book.title,
}),
});
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to delete book: {{title}}', {
title: book.title,
}),
type: 'error',
});
}
};
@@ -325,6 +359,8 @@ const LibraryPage = () => {
handleImportBooks={handleImportBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
booksTransferProgress={booksTransferProgress}
/>
</Suspense>
</div>
@@ -9,7 +9,7 @@ import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { parseSSMLLang } from '@/utils/ssml';
import { getOSPlatform } from '@/utils/misc';
import { throttle } from '@/utils/ui';
import { throttle } from '@/utils/throttle';
import { isPWA } from '@/services/environment';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
@@ -24,7 +24,7 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
const [loading, setLoading] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [bookMeta, setBookMeta] = useState<BookDoc['metadata'] | null>(null);
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
@@ -50,8 +50,9 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
setShowDeleteAlert(true);
};
const confirmDelete = () => {
updateBook(envConfig, book, true);
const confirmDelete = async () => {
await appService?.deleteBook(book, !!book.uploadedAt);
await updateBook(envConfig, book);
handleClose();
setShowDeleteAlert(false);
};
+47 -19
View File
@@ -1,5 +1,13 @@
import { getAPIBaseUrl, isWebAppPlatform } from '@/services/environment';
import { getAccessToken, getUserID } from '@/utils/access';
import {
tauriUpload,
tauriDownload,
webUpload,
webDownload,
ProgressHandler,
ProgressPayload,
} from '@/utils/transfer';
const API_ENDPOINTS = {
upload: getAPIBaseUrl() + '/storage/upload',
@@ -28,7 +36,31 @@ const fetchWithAuth = async (url: string, options: RequestInit) => {
return response;
};
export const uploadFile = async (file: File, bookHash?: string) => {
export const createProgressHandler = (
totalFiles: number,
completedFilesRef: { count: number },
onProgress?: ProgressHandler,
) => {
return (progress: ProgressPayload) => {
const fileProgress = progress.progress / progress.total;
const overallProgress = ((completedFilesRef.count + fileProgress) / totalFiles) * 100;
if (onProgress) {
onProgress({
progress: overallProgress,
total: 100,
transferSpeed: progress.transferSpeed,
});
}
};
};
export const uploadFile = async (
file: File,
fileFullPath: string,
onProgress?: ProgressHandler,
bookHash?: string,
) => {
try {
const response = await fetchWithAuth(API_ENDPOINTS.upload, {
method: 'POST',
@@ -43,17 +75,10 @@ export const uploadFile = async (file: File, bookHash?: string) => {
});
const { uploadUrl } = await response.json();
const fetch = isWebAppPlatform()
? window.fetch
: (await import('@tauri-apps/plugin-http')).fetch;
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: isWebAppPlatform() ? file : await file.arrayBuffer(),
});
if (!uploadResponse.ok) {
console.error('File upload failed:', await uploadResponse.text());
throw new Error('File upload failed');
if (isWebAppPlatform()) {
await webUpload(file, uploadUrl, onProgress);
} else {
await tauriUpload(uploadUrl, fileFullPath, 'PUT', onProgress);
}
} catch (error) {
console.error('File upload failed:', error);
@@ -61,7 +86,11 @@ export const uploadFile = async (file: File, bookHash?: string) => {
}
};
export const downloadFile = async (filePath: string) => {
export const downloadFile = async (
filePath: string,
fileFullPath: string,
onProgress?: ProgressHandler,
) => {
try {
const userId = await getUserID();
if (!userId) {
@@ -78,13 +107,12 @@ export const downloadFile = async (filePath: string) => {
const { downloadUrl } = await response.json();
const downloadResponse = await fetch(downloadUrl);
if (!downloadResponse.ok) {
console.error('Error downloading file:', await downloadResponse.text());
throw new Error('File download failed');
if (isWebAppPlatform()) {
return await webDownload(downloadUrl, onProgress);
} else {
await tauriDownload(downloadUrl, fileFullPath, onProgress);
return;
}
return await downloadResponse.blob();
} catch (error) {
console.error('File download failed:', error);
throw new Error('File download failed');
+80 -31
View File
@@ -29,9 +29,11 @@ import {
CLOUD_BOOKS_SUBDIR,
DEFAULT_MOBILE_VIEW_SETTINGS,
} from './constants';
import { isWebAppPlatform } from './environment';
import { getOSPlatform, isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { downloadFile, uploadFile, deleteFile } from '@/libs/storage';
import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage';
import { ProgressHandler } from '@/utils/transfer';
export abstract class BaseAppService implements AppService {
isMobile: boolean = ['android', 'ios'].includes(getOSPlatform());
@@ -195,34 +197,62 @@ export abstract class BaseAppService implements AppService {
}
async deleteBook(book: Book, includingUploaded = false): Promise<void> {
for (const fp of [getFilename(book), getCoverFilename(book)]) {
if (await this.fs.exists(fp, 'Books')) {
await this.fs.removeFile(fp, 'Books');
}
const fps = [getFilename(book), getCoverFilename(book)];
const localDeleteFps = (
await Promise.all(fps.map(async (fp) => ((await this.fs.exists(fp, 'Books')) ? fp : null)))
).filter(Boolean) as string[];
for (const fp of localDeleteFps) {
await this.fs.removeFile(fp, 'Books');
}
for (const fp of fps) {
if (includingUploaded) {
console.log('Deleting uploaded file:', fp);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`;
await deleteFile(cfp);
try {
deleteFile(cfp);
} catch (error) {
console.log('Failed to delete uploaded file:', error);
}
}
}
book.deletedAt = Date.now();
book.downloadedAt = null;
if (includingUploaded) {
book.uploadedAt = null;
}
}
async uploadBook(book: Book): Promise<void> {
async uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void> {
let file: File;
let uploaded = false;
for (const fp of [getFilename(book), getCoverFilename(book)]) {
if (await this.fs.exists(fp, 'Books')) {
const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`;
if (this.appPlatform === 'web') {
const content = await this.fs.readFile(fp, 'Books', 'binary');
file = new File([content], cfp);
} else {
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), cfp).open();
}
console.log('Uploading file:', fp);
await uploadFile(file, book.hash);
uploaded = true;
const completedFiles = { count: 0 };
const fps = (
await Promise.all(
[getCoverFilename(book), getFilename(book)].map(async (fp) =>
(await this.fs.exists(fp, 'Books')) ? fp : null,
),
)
).filter(Boolean) as string[];
if (!fps.includes(getFilename(book)) && book.url) {
// download the book from the URL
const fileobj = await new RemoteFile(book.url).open();
await this.fs.writeFile(getFilename(book), 'Books', await fileobj.arrayBuffer());
fps.push(getFilename(book));
}
const handleProgress = createProgressHandler(fps.length, completedFiles, onProgress);
for (const fp of fps) {
const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`;
const fullpath = `${this.localBooksDir}/${fp}`;
if (this.appPlatform === 'web') {
const content = await this.fs.readFile(fp, 'Books', 'binary');
file = new File([content], cfp);
} else {
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), cfp).open();
}
console.log('Uploading file:', fp);
await uploadFile(file, fullpath, handleProgress, book.hash);
uploaded = true;
completedFiles.count++;
}
if (uploaded) {
book.deletedAt = null;
@@ -234,31 +264,50 @@ export abstract class BaseAppService implements AppService {
}
}
async downloadBook(book: Book, onlyCover = false): Promise<void> {
const bookFp = getFilename(book);
const coverFp = getCoverFilename(book);
const fps = [coverFp];
if (!onlyCover) {
fps.push(bookFp);
}
async downloadBook(book: Book, onlyCover = false, onProgress?: ProgressHandler): Promise<void> {
const fps = onlyCover ? [getCoverFilename(book)] : [getCoverFilename(book), getFilename(book)];
let bookDownloaded = false;
const completedFiles = { count: 0 };
const toDownloadFps = (
await Promise.all(
[getFilename(book), getCoverFilename(book)].map(async (fp) =>
(await this.fs.exists(fp, 'Books')) ? null : fp,
),
)
).filter(Boolean) as string[];
const handleProgress = createProgressHandler(toDownloadFps.length, completedFiles, onProgress);
for (const fp of fps) {
let downloaded = false;
const existed = await this.fs.exists(fp, 'Books');
const existed = !toDownloadFps.includes(fp);
if (existed) {
downloaded = true;
} else {
console.log('Downloading file:', fp);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`;
const fileobj = (await downloadFile(cfp)) as Blob;
const fullpath = `${this.localBooksDir}/${fp}`;
if (!(await this.fs.exists(getDir(book), 'Books'))) {
await this.fs.createDir(getDir(book), 'Books');
}
await this.fs.writeFile(fp, 'Books', await fileobj.arrayBuffer());
downloaded = true;
try {
const result = await downloadFile(cfp, fullpath, handleProgress);
if (isWebAppPlatform()) {
const fileobj = result as Blob;
await this.fs.writeFile(fp, 'Books', await fileobj.arrayBuffer());
downloaded = true;
} else {
downloaded = await this.fs.exists(fp, 'Books');
}
} catch {
if (fp === getCoverFilename(book)) {
console.log('Failed to download cover image:', fp);
} else {
throw new Error('Failed to download book file');
}
}
completedFiles.count++;
}
if (fp === bookFp) {
if (fp === getFilename(book)) {
bookDownloaded = downloaded;
}
}
+3 -10
View File
@@ -7,7 +7,7 @@ interface LibraryState {
checkOpenWithBooks: boolean;
clearOpenWithBooks: () => void;
setLibrary: (books: Book[]) => void;
updateBook: (envConfig: EnvConfigType, book: Book, isDelete?: boolean) => void;
updateBook: (envConfig: EnvConfigType, book: Book) => void;
}
export const useLibraryStore = create<LibraryState>((set, get) => ({
@@ -15,19 +15,12 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
checkOpenWithBooks: true,
clearOpenWithBooks: () => set({ checkOpenWithBooks: false }),
setLibrary: (books) => set({ library: books }),
updateBook: async (envConfig: EnvConfigType, book: Book, isDelete = false) => {
updateBook: async (envConfig: EnvConfigType, book: Book) => {
const appService = await envConfig.getAppService();
const { library } = get();
const bookIndex = library.findIndex((b) => b.hash === book.hash);
if (bookIndex !== -1) {
if (isDelete) {
appService.deleteBook(book, !!book.uploadedAt);
book.deletedAt = Date.now();
book.uploadedAt = null;
book.downloadedAt = null;
} else {
library[bookIndex] = book;
}
library[bookIndex] = book;
}
set({ library });
appService.saveLibraryBooks(library);
+3 -2
View File
@@ -1,6 +1,7 @@
import { SystemSettings } from './settings';
import { Book, BookConfig, BookContent } from './book';
import { BookDoc } from '@/libs/document';
import { ProgressHandler } from '@/utils/transfer';
export type AppPlatform = 'web' | 'tauri';
export type BaseDir = 'Books' | 'Settings' | 'Data' | 'Log' | 'Cache' | 'None';
@@ -40,8 +41,8 @@ export interface AppService {
overwrite?: boolean,
): Promise<Book | null>;
deleteBook(book: Book, includingUploaded?: boolean): Promise<void>;
uploadBook(book: Book): Promise<void>;
downloadBook(book: Book, onlyCover?: boolean): Promise<void>;
uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void>;
downloadBook(book: Book, onlyCover?: boolean, onProgress?: ProgressHandler): Promise<void>;
loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig>;
fetchBookDetails(book: Book, settings: SystemSettings): Promise<BookDoc['metadata']>;
saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings): Promise<void>;
@@ -4,6 +4,7 @@ export const throttle = <T extends (...args: Parameters<T>) => void | Promise<vo
): ((...args: Parameters<T>) => void) => {
let lastCall = 0;
let timeout: ReturnType<typeof setTimeout> | null = null;
let lastArgs: Parameters<T> | null = null;
return (...args: Parameters<T>): void => {
const now = Date.now();
@@ -21,8 +22,17 @@ export const throttle = <T extends (...args: Parameters<T>) => void | Promise<vo
timeout = null;
}
callFunc();
} else if (!timeout) {
timeout = setTimeout(callFunc, remaining);
} else {
lastArgs = args;
if (!timeout) {
timeout = setTimeout(() => {
timeout = null;
if (lastArgs) {
func(...(lastArgs as Parameters<T>));
lastArgs = null;
}
}, remaining);
}
}
};
};
+125
View File
@@ -0,0 +1,125 @@
import { invoke, Channel } from '@tauri-apps/api/core';
export type UploadMethod = 'POST' | 'PUT';
export interface ProgressPayload {
progress: number;
total: number;
transferSpeed: number;
}
export type ProgressHandler = (progress: ProgressPayload) => void;
export const webUpload = (file: File, uploadUrl: string, onProgress?: ProgressHandler) => {
return new Promise<void>((resolve, reject) => {
const startTime = Date.now();
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadUrl, true);
xhr.upload.onprogress = (event) => {
if (onProgress && event.lengthComputable) {
onProgress({
progress: event.loaded,
total: event.total,
transferSpeed: event.loaded / ((Date.now() - startTime) / 1000),
});
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.send(file);
});
};
export const webDownload = async (downloadUrl: string, onProgress?: ProgressHandler) => {
const response = await fetch(downloadUrl);
if (!response.ok) throw new Error('File download failed');
const contentLength = response.headers.get('Content-Length');
if (!contentLength) throw new Error('Cannot track progress: Content-Length missing');
const totalSize = parseInt(contentLength, 10);
let receivedSize = 0;
const reader = response.body!.getReader();
const chunks: Uint8Array[] = [];
const startTime = Date.now();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
receivedSize += value.length;
if (onProgress) {
onProgress({
progress: receivedSize,
total: totalSize,
transferSpeed: receivedSize / ((Date.now() - startTime) / 1000),
});
}
}
return new Blob(chunks);
};
export const tauriUpload = async (
url: string,
filePath: string,
method: UploadMethod,
progressHandler?: ProgressHandler,
headers?: Map<string, string>,
): Promise<string> => {
const ids = new Uint32Array(1);
window.crypto.getRandomValues(ids);
const id = ids[0];
const onProgress = new Channel<ProgressPayload>();
if (progressHandler) {
onProgress.onmessage = progressHandler;
}
return await invoke('upload_file', {
id,
url,
filePath,
method,
headers: headers ?? {},
onProgress,
});
};
export const tauriDownload = async (
url: string,
filePath: string,
progressHandler?: ProgressHandler,
headers?: Map<string, string>,
body?: string,
): Promise<void> => {
const ids = new Uint32Array(1);
window.crypto.getRandomValues(ids);
const id = ids[0];
const onProgress = new Channel<ProgressPayload>();
if (progressHandler) {
onProgress.onmessage = progressHandler;
}
await invoke('download_file', {
id,
url,
filePath,
headers: headers ?? {},
onProgress,
body,
});
};