edit
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using BookReader.Services;
|
using BookReader.Platforms.Android.Services;
|
||||||
|
using BookReader.Services;
|
||||||
using BookReader.ViewModels;
|
using BookReader.ViewModels;
|
||||||
using BookReader.Views;
|
using BookReader.Views;
|
||||||
using CommunityToolkit.Maui;
|
using CommunityToolkit.Maui;
|
||||||
@@ -26,24 +27,21 @@ public static class MauiProgram
|
|||||||
builder.Logging.AddDebug();
|
builder.Logging.AddDebug();
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Register Services (Singleton for shared state)
|
|
||||||
builder.Services.AddSingleton<IDatabaseService, DatabaseService>();
|
builder.Services.AddSingleton<IDatabaseService, DatabaseService>();
|
||||||
builder.Services.AddSingleton<IBookParserService, BookParserService>();
|
builder.Services.AddSingleton<IBookParserService, BookParserService>();
|
||||||
builder.Services.AddSingleton<ISettingsService, SettingsService>();
|
builder.Services.AddSingleton<ISettingsService, SettingsService>();
|
||||||
builder.Services.AddSingleton<INavigationService, NavigationService>();
|
builder.Services.AddSingleton<INavigationService, NavigationService>();
|
||||||
builder.Services.AddSingleton<ICoverCacheService, LruCoverCacheService>();
|
builder.Services.AddSingleton<ICoverCacheService, LruCoverCacheService>();
|
||||||
builder.Services.AddSingleton<ICachedImageLoadingService, CachedImageLoadingService>();
|
builder.Services.AddSingleton<ICachedImageLoadingService, CachedImageLoadingService>();
|
||||||
|
builder.Services.AddSingleton<IBookImportPickerService, AndroidBookImportPickerService>();
|
||||||
|
|
||||||
// HTTP Client for Calibre
|
|
||||||
builder.Services.AddHttpClient<ICalibreWebService, CalibreWebService>();
|
builder.Services.AddHttpClient<ICalibreWebService, CalibreWebService>();
|
||||||
|
|
||||||
// Register ViewModels
|
|
||||||
builder.Services.AddTransient<BookshelfViewModel>();
|
builder.Services.AddTransient<BookshelfViewModel>();
|
||||||
builder.Services.AddTransient<ReaderViewModel>();
|
builder.Services.AddTransient<ReaderViewModel>();
|
||||||
builder.Services.AddTransient<SettingsViewModel>();
|
builder.Services.AddTransient<SettingsViewModel>();
|
||||||
builder.Services.AddTransient<CalibreLibraryViewModel>();
|
builder.Services.AddTransient<CalibreLibraryViewModel>();
|
||||||
|
|
||||||
// Register Pages
|
|
||||||
builder.Services.AddTransient<BookshelfPage>();
|
builder.Services.AddTransient<BookshelfPage>();
|
||||||
builder.Services.AddTransient<ReaderPage>();
|
builder.Services.AddTransient<ReaderPage>();
|
||||||
builder.Services.AddTransient<SettingsPage>();
|
builder.Services.AddTransient<SettingsPage>();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Android.App;
|
using Android.App;
|
||||||
|
using Android.Content;
|
||||||
using Android.Content.PM;
|
using Android.Content.PM;
|
||||||
using Android.OS;
|
using Android.OS;
|
||||||
|
using BookReader.Platforms.Android.Services;
|
||||||
|
|
||||||
namespace BookReader;
|
namespace BookReader;
|
||||||
|
|
||||||
@@ -16,7 +18,12 @@ public class MainActivity : MauiAppCompatActivity
|
|||||||
{
|
{
|
||||||
base.OnCreate(savedInstanceState);
|
base.OnCreate(savedInstanceState);
|
||||||
|
|
||||||
// Keep screen on while reading
|
|
||||||
Window?.AddFlags(Android.Views.WindowManagerFlags.KeepScreenOn);
|
Window?.AddFlags(Android.Views.WindowManagerFlags.KeepScreenOn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
|
||||||
|
{
|
||||||
|
base.OnActivityResult(requestCode, resultCode, data);
|
||||||
|
AndroidActivityResultRegistry.Dispatch(requestCode, resultCode, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
using Android.App;
|
||||||
|
using Android.Content;
|
||||||
|
using Android.Database;
|
||||||
|
using Android.Provider;
|
||||||
|
using Android.Webkit;
|
||||||
|
using BookReader.Services;
|
||||||
|
using Microsoft.Maui.ApplicationModel;
|
||||||
|
using ActivityResult = Android.App.Result;
|
||||||
|
using AndroidUri = global::Android.Net.Uri;
|
||||||
|
|
||||||
|
namespace BookReader.Platforms.Android.Services;
|
||||||
|
|
||||||
|
internal sealed class AndroidActivityResultEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public AndroidActivityResultEventArgs(int requestCode, ActivityResult resultCode, Intent? data)
|
||||||
|
{
|
||||||
|
RequestCode = requestCode;
|
||||||
|
ResultCode = resultCode;
|
||||||
|
Data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int RequestCode { get; }
|
||||||
|
public ActivityResult ResultCode { get; }
|
||||||
|
public Intent? Data { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class AndroidActivityResultRegistry
|
||||||
|
{
|
||||||
|
public static event EventHandler<AndroidActivityResultEventArgs>? ActivityResultReceived;
|
||||||
|
|
||||||
|
public static void Dispatch(int requestCode, ActivityResult resultCode, Intent? data)
|
||||||
|
{
|
||||||
|
ActivityResultReceived?.Invoke(null, new AndroidActivityResultEventArgs(requestCode, resultCode, data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AndroidBookImportPickerService : IBookImportPickerService
|
||||||
|
{
|
||||||
|
private const int PickBookRequestCode = 41071;
|
||||||
|
|
||||||
|
private static readonly string[] SupportedMimeTypes =
|
||||||
|
{
|
||||||
|
"application/epub+zip",
|
||||||
|
"application/x-fictionbook+xml",
|
||||||
|
"application/xml",
|
||||||
|
"text/xml",
|
||||||
|
"application/octet-stream"
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly SemaphoreSlim _pickLock = new(1, 1);
|
||||||
|
private TaskCompletionSource<(ActivityResult ResultCode, Intent? Data)>? _pendingPick;
|
||||||
|
|
||||||
|
public AndroidBookImportPickerService()
|
||||||
|
{
|
||||||
|
AndroidActivityResultRegistry.ActivityResultReceived += OnActivityResultReceived;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SelectedBookFile?> PickBookFileAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _pickLock.WaitAsync(cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var activity = Platform.CurrentActivity
|
||||||
|
?? throw new InvalidOperationException("Текущая Android activity недоступна.");
|
||||||
|
|
||||||
|
var pickCompletionSource = new TaskCompletionSource<(ActivityResult ResultCode, Intent? Data)>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
_pendingPick = pickCompletionSource;
|
||||||
|
|
||||||
|
using var registration = cancellationToken.Register(() => pickCompletionSource.TrySetCanceled(cancellationToken));
|
||||||
|
|
||||||
|
await MainThread.InvokeOnMainThreadAsync(() =>
|
||||||
|
{
|
||||||
|
var intent = new Intent(Intent.ActionOpenDocument);
|
||||||
|
intent.AddCategory(Intent.CategoryOpenable);
|
||||||
|
intent.SetType("*/*");
|
||||||
|
intent.PutExtra(Intent.ExtraMimeTypes, SupportedMimeTypes);
|
||||||
|
intent.AddFlags(ActivityFlags.GrantReadUriPermission | ActivityFlags.GrantPersistableUriPermission);
|
||||||
|
|
||||||
|
#pragma warning disable CS0618
|
||||||
|
activity.StartActivityForResult(Intent.CreateChooser(intent, "Выберите книгу"), PickBookRequestCode);
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
});
|
||||||
|
|
||||||
|
var (resultCode, data) = await pickCompletionSource.Task;
|
||||||
|
if (resultCode != ActivityResult.Ok || data?.Data == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var documentUri = data.Data;
|
||||||
|
TryTakePersistablePermission(activity, documentUri, data.Flags);
|
||||||
|
|
||||||
|
return await CopyDocumentToCacheAsync(activity, documentUri, cancellationToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _pendingPick, null);
|
||||||
|
_pickLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnActivityResultReceived(object? sender, AndroidActivityResultEventArgs args)
|
||||||
|
{
|
||||||
|
if (args.RequestCode != PickBookRequestCode)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingPick?.TrySetResult((args.ResultCode, args.Data));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryTakePersistablePermission(Activity activity, AndroidUri documentUri, ActivityFlags grantedFlags)
|
||||||
|
{
|
||||||
|
var persistableFlags = grantedFlags & (ActivityFlags.GrantReadUriPermission | ActivityFlags.GrantWriteUriPermission);
|
||||||
|
if ((persistableFlags & ActivityFlags.GrantReadUriPermission) == 0)
|
||||||
|
{
|
||||||
|
persistableFlags |= ActivityFlags.GrantReadUriPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
activity.ContentResolver?.TakePersistableUriPermission(documentUri, persistableFlags);
|
||||||
|
}
|
||||||
|
catch (Java.Lang.SecurityException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Java.Lang.UnsupportedOperationException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<SelectedBookFile> CopyDocumentToCacheAsync(Activity activity, AndroidUri documentUri, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var contentResolver = activity.ContentResolver
|
||||||
|
?? throw new InvalidOperationException("ContentResolver недоступен.");
|
||||||
|
|
||||||
|
var originalFileName = ResolveFileName(contentResolver, documentUri);
|
||||||
|
var extension = Path.GetExtension(originalFileName);
|
||||||
|
if (string.IsNullOrWhiteSpace(extension))
|
||||||
|
{
|
||||||
|
var mimeType = contentResolver.GetType(documentUri);
|
||||||
|
var inferredExtension = MimeTypeMap.Singleton?.GetExtensionFromMimeType(mimeType);
|
||||||
|
if (!string.IsNullOrWhiteSpace(inferredExtension))
|
||||||
|
{
|
||||||
|
extension = $".{inferredExtension}";
|
||||||
|
originalFileName = $"{originalFileName}{extension}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tempPath = Path.Combine(FileSystem.CacheDirectory, $"{Guid.NewGuid()}{extension}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var destinationStream = File.Create(tempPath);
|
||||||
|
await using var sourceStream = await OpenReadStreamAsync(contentResolver, documentUri, cancellationToken);
|
||||||
|
await sourceStream.CopyToAsync(destinationStream, cancellationToken);
|
||||||
|
await destinationStream.FlushAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new SelectedBookFile(originalFileName, tempPath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(tempPath))
|
||||||
|
{
|
||||||
|
File.Delete(tempPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<Stream> OpenReadStreamAsync(ContentResolver contentResolver, AndroidUri documentUri, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.Run(() =>
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
return contentResolver.OpenInputStream(documentUri)
|
||||||
|
?? throw new InvalidOperationException("Не удалось открыть выбранный документ.");
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveFileName(ContentResolver contentResolver, AndroidUri documentUri)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using ICursor? cursor = contentResolver.Query(documentUri, new[] { IOpenableColumns.DisplayName }, null, null, null);
|
||||||
|
if (cursor != null && cursor.MoveToFirst())
|
||||||
|
{
|
||||||
|
var displayNameIndex = cursor.GetColumnIndex(IOpenableColumns.DisplayName);
|
||||||
|
if (displayNameIndex >= 0)
|
||||||
|
{
|
||||||
|
var displayName = cursor.GetString(displayNameIndex);
|
||||||
|
if (!string.IsNullOrWhiteSpace(displayName))
|
||||||
|
{
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return documentUri.LastPathSegment?.Split('/').LastOrDefault() ?? "book";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,31 @@
|
|||||||
using BookReader.Models;
|
using BookReader.Models;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
namespace BookReader.Services;
|
namespace BookReader.Services;
|
||||||
|
|
||||||
public class CalibreWebService : ICalibreWebService
|
public class CalibreWebService : ICalibreWebService
|
||||||
{
|
{
|
||||||
|
private static readonly XNamespace AtomNamespace = "http://www.w3.org/2005/Atom";
|
||||||
|
private const string OpdsAcquisitionRel = "http://opds-spec.org/acquisition";
|
||||||
|
private const string OpdsImageRel = "http://opds-spec.org/image";
|
||||||
|
private const string OpdsThumbnailRel = "http://opds-spec.org/image/thumbnail";
|
||||||
|
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly ICoverCacheService _coverCacheService;
|
private readonly ICoverCacheService _coverCacheService;
|
||||||
private string _baseUrl = string.Empty;
|
private string _baseUrl = string.Empty;
|
||||||
|
private CalibreBackendKind _backendKind = CalibreBackendKind.Unknown;
|
||||||
|
|
||||||
|
private enum CalibreBackendKind
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
ContentServerAjax,
|
||||||
|
Opds
|
||||||
|
}
|
||||||
|
|
||||||
public CalibreWebService(HttpClient httpClient, ICoverCacheService coverCacheService)
|
public CalibreWebService(HttpClient httpClient, ICoverCacheService coverCacheService)
|
||||||
{
|
{
|
||||||
@@ -20,7 +36,13 @@ public class CalibreWebService : ICalibreWebService
|
|||||||
|
|
||||||
public void Configure(string url, string username, string password)
|
public void Configure(string url, string username, string password)
|
||||||
{
|
{
|
||||||
_baseUrl = url.Trim().TrimEnd('/');
|
if (!TryCreateSupportedRemoteUri(url, out var serverUri))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Адрес сервера Calibre должен начинаться с http:// или https://.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_baseUrl = serverUri.ToString().TrimEnd('/');
|
||||||
|
_backendKind = CalibreBackendKind.Unknown;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(username))
|
if (!string.IsNullOrWhiteSpace(username))
|
||||||
{
|
{
|
||||||
@@ -39,8 +61,8 @@ public class CalibreWebService : ICalibreWebService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
Configure(url, username, password);
|
Configure(url, username, password);
|
||||||
var response = await _httpClient.GetAsync($"{_baseUrl}/ajax/search?query=&num=1");
|
var backendKind = await DetectBackendAsync(forceRefresh: true);
|
||||||
return response.IsSuccessStatusCode;
|
return backendKind != CalibreBackendKind.Unknown;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -55,10 +77,148 @@ public class CalibreWebService : ICalibreWebService
|
|||||||
return Result<List<CalibreBook>>.Failure("Сначала укажите адрес сервера Calibre в настройках.");
|
return Result<List<CalibreBook>>.Failure("Сначала укажите адрес сервера Calibre в настройках.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var books = new List<CalibreBook>();
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var backendKind = await DetectBackendAsync();
|
||||||
|
return backendKind switch
|
||||||
|
{
|
||||||
|
CalibreBackendKind.ContentServerAjax => await GetBooksFromAjaxAsync(searchQuery, page, pageSize),
|
||||||
|
CalibreBackendKind.Opds => await GetBooksFromOpdsAsync(searchQuery, page, pageSize),
|
||||||
|
_ => Result<List<CalibreBook>>.Failure("Не удалось определить API сервера Calibre. Для calibre-web нужен доступный OPDS, для calibre-server — API /ajax.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException ex)
|
||||||
|
{
|
||||||
|
return Result<List<CalibreBook>>.Failure(new TimeoutException("Сервер Calibre отвечает слишком долго.", ex));
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized || ex.StatusCode == HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
return Result<List<CalibreBook>>.Failure("Сервер Calibre отклонил авторизацию. Проверьте логин и пароль.");
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return Result<List<CalibreBook>>.Failure($"Не удалось загрузить каталог Calibre: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return Result<List<CalibreBook>>.Failure(ex.Message);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return Result<List<CalibreBook>>.Failure(ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result<List<CalibreBook>>.Failure($"Не удалось загрузить каталог Calibre: {ex.GetBaseException().Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> DownloadBookAsync(CalibreBook book, IProgress<double>? progress = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(book.DownloadUrl))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("У книги нет ссылки на скачивание.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryCreateSupportedRemoteUri(book.DownloadUrl, out var downloadUri))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Сервер Calibre вернул неподдерживаемую ссылку на скачивание. Поддерживаются только http:// и https://.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var booksDir = Path.Combine(FileSystem.AppDataDirectory, Constants.Files.BooksFolder);
|
||||||
|
Directory.CreateDirectory(booksDir);
|
||||||
|
|
||||||
|
var fileName = $"{Guid.NewGuid()}.{book.Format}";
|
||||||
|
var filePath = Path.Combine(booksDir, fileName);
|
||||||
|
|
||||||
|
using var response = await _httpClient.GetAsync(downloadUri, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
var hasAuthorizationHeader = _httpClient.DefaultRequestHeaders.Authorization != null;
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
var message = hasAuthorizationHeader
|
||||||
|
? $"Сервер Calibre отклонил авторизацию при скачивании книги. Проверьте логин и пароль в настройках. URL: {downloadUri}"
|
||||||
|
: $"Сервер Calibre требует авторизацию для скачивания книги. Укажите логин и пароль в настройках. URL: {downloadUri}";
|
||||||
|
|
||||||
|
throw new UnauthorizedAccessException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorSnippet = await ReadErrorSnippetAsync(response);
|
||||||
|
var authHint = !hasAuthorizationHeader && IsOpdsDownloadUri(downloadUri)
|
||||||
|
? " Каталог OPDS может быть публичным, а скачивание требовать логин и пароль."
|
||||||
|
: string.Empty;
|
||||||
|
var responseHint = string.IsNullOrWhiteSpace(errorSnippet)
|
||||||
|
? string.Empty
|
||||||
|
: $" Ответ сервера: {errorSnippet}";
|
||||||
|
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Сервер Calibre вернул {(int)response.StatusCode} {response.ReasonPhrase} при скачивании книги. URL: {downloadUri}.{authHint}{responseHint}",
|
||||||
|
null,
|
||||||
|
response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalBytes = response.Content.Headers.ContentLength ?? -1;
|
||||||
|
var bytesRead = 0L;
|
||||||
|
|
||||||
|
using var contentStream = await response.Content.ReadAsStreamAsync();
|
||||||
|
using var fileStream = File.Create(filePath);
|
||||||
|
|
||||||
|
var buffer = new byte[Constants.Network.DownloadBufferSize];
|
||||||
|
int read;
|
||||||
|
|
||||||
|
while ((read = await contentStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
||||||
|
{
|
||||||
|
await fileStream.WriteAsync(buffer, 0, read);
|
||||||
|
bytesRead += read;
|
||||||
|
|
||||||
|
if (totalBytes > 0)
|
||||||
|
{
|
||||||
|
progress?.Report((double)bytesRead / totalBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CalibreBackendKind> DetectBackendAsync(bool forceRefresh = false)
|
||||||
|
{
|
||||||
|
if (!forceRefresh && _backendKind != CalibreBackendKind.Unknown)
|
||||||
|
{
|
||||||
|
return _backendKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_baseUrl))
|
||||||
|
{
|
||||||
|
return CalibreBackendKind.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var ajaxResponse = await _httpClient.GetAsync($"{_baseUrl}/ajax/search?query=&num=1");
|
||||||
|
if (ajaxResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_backendKind = CalibreBackendKind.ContentServerAjax;
|
||||||
|
return _backendKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var opdsResponse = await _httpClient.GetAsync($"{_baseUrl}/opds");
|
||||||
|
if (opdsResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var responseBody = await opdsResponse.Content.ReadAsStringAsync();
|
||||||
|
if (IsOpdsFeed(responseBody))
|
||||||
|
{
|
||||||
|
_backendKind = CalibreBackendKind.Opds;
|
||||||
|
return _backendKind;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_backendKind = CalibreBackendKind.Unknown;
|
||||||
|
return _backendKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Result<List<CalibreBook>>> GetBooksFromAjaxAsync(string? searchQuery, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var books = new List<CalibreBook>();
|
||||||
var offset = page * pageSize;
|
var offset = page * pageSize;
|
||||||
var query = string.IsNullOrWhiteSpace(searchQuery) ? string.Empty : Uri.EscapeDataString(searchQuery.Trim());
|
var query = string.IsNullOrWhiteSpace(searchQuery) ? string.Empty : Uri.EscapeDataString(searchQuery.Trim());
|
||||||
var url = $"{_baseUrl}/ajax/search?query={query}&num={pageSize}&offset={offset}&sort=timestamp&sort_order=desc";
|
var url = $"{_baseUrl}/ajax/search?query={query}&num={pageSize}&offset={offset}&sort=timestamp&sort_order=desc";
|
||||||
@@ -73,7 +233,7 @@ public class CalibreWebService : ICalibreWebService
|
|||||||
await semaphore.WaitAsync();
|
await semaphore.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await LoadBookDataAsync(bookId);
|
return await LoadBookDataFromAjaxAsync(bookId);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -86,21 +246,40 @@ public class CalibreWebService : ICalibreWebService
|
|||||||
|
|
||||||
return Result<List<CalibreBook>>.Success(books);
|
return Result<List<CalibreBook>>.Success(books);
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException ex)
|
|
||||||
|
private async Task<Result<List<CalibreBook>>> GetBooksFromOpdsAsync(string? searchQuery, int page, int pageSize)
|
||||||
{
|
{
|
||||||
return Result<List<CalibreBook>>.Failure(new TimeoutException("Сервер Calibre отвечает слишком долго.", ex));
|
var requestUrl = string.IsNullOrWhiteSpace(searchQuery)
|
||||||
}
|
? ToAbsoluteUrl("/opds/books/letter/00")
|
||||||
catch (HttpRequestException ex)
|
: ToAbsoluteUrl($"/opds/search/{Uri.EscapeDataString(searchQuery.Trim())}");
|
||||||
|
|
||||||
|
using var response = await _httpClient.GetAsync(requestUrl);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
{
|
{
|
||||||
return Result<List<CalibreBook>>.Failure(new HttpRequestException("Не удалось подключиться к серверу Calibre.", ex));
|
return Result<List<CalibreBook>>.Failure("На сервере calibre-web не найден каталог OPDS.");
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Result<List<CalibreBook>>.Failure(new Exception("Не удалось загрузить каталог Calibre.", ex));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<CalibreBook?> LoadBookDataAsync(int bookId)
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var xml = await response.Content.ReadAsStringAsync();
|
||||||
|
var document = XDocument.Parse(xml);
|
||||||
|
var allBooks = document.Root?
|
||||||
|
.Elements(AtomNamespace + "entry")
|
||||||
|
.Select(ParseOpdsBook)
|
||||||
|
.Where(book => book != null)
|
||||||
|
.Cast<CalibreBook>()
|
||||||
|
.ToList() ?? new List<CalibreBook>();
|
||||||
|
|
||||||
|
var pagedBooks = allBooks
|
||||||
|
.Skip(page * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
await PopulateCoverImagesAsync(pagedBooks);
|
||||||
|
return Result<List<CalibreBook>>.Success(pagedBooks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CalibreBook?> LoadBookDataFromAjaxAsync(int bookId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -130,21 +309,7 @@ public class CalibreWebService : ICalibreWebService
|
|||||||
DownloadUrl = $"{_baseUrl}/get/{supportedFormat}/{bookId}"
|
DownloadUrl = $"{_baseUrl}/get/{supportedFormat}/{bookId}"
|
||||||
};
|
};
|
||||||
|
|
||||||
var cacheKey = $"cover_{bookId}";
|
await PopulateCoverImageAsync(calibreBook);
|
||||||
calibreBook.CoverImage = await _coverCacheService.GetCoverAsync(cacheKey);
|
|
||||||
|
|
||||||
if (calibreBook.CoverImage == null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
calibreBook.CoverImage = await _httpClient.GetByteArrayAsync(calibreBook.CoverUrl);
|
|
||||||
await _coverCacheService.SetCoverAsync(cacheKey, calibreBook.CoverImage);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return calibreBook;
|
return calibreBook;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -153,37 +318,243 @@ public class CalibreWebService : ICalibreWebService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> DownloadBookAsync(CalibreBook book, IProgress<double>? progress = null)
|
private CalibreBook? ParseOpdsBook(XElement entry)
|
||||||
{
|
{
|
||||||
var booksDir = Path.Combine(FileSystem.AppDataDirectory, Constants.Files.BooksFolder);
|
try
|
||||||
Directory.CreateDirectory(booksDir);
|
|
||||||
|
|
||||||
var fileName = $"{Guid.NewGuid()}.{book.Format}";
|
|
||||||
var filePath = Path.Combine(booksDir, fileName);
|
|
||||||
|
|
||||||
using var response = await _httpClient.GetAsync(book.DownloadUrl, HttpCompletionOption.ResponseHeadersRead);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var totalBytes = response.Content.Headers.ContentLength ?? -1;
|
|
||||||
var bytesRead = 0L;
|
|
||||||
|
|
||||||
using var contentStream = await response.Content.ReadAsStreamAsync();
|
|
||||||
using var fileStream = File.Create(filePath);
|
|
||||||
|
|
||||||
var buffer = new byte[Constants.Network.DownloadBufferSize];
|
|
||||||
int read;
|
|
||||||
|
|
||||||
while ((read = await contentStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
|
||||||
{
|
{
|
||||||
await fileStream.WriteAsync(buffer, 0, read);
|
var links = entry.Elements(AtomNamespace + "link").ToList();
|
||||||
bytesRead += read;
|
var acquisitionLink = links.FirstOrDefault(link =>
|
||||||
|
|
||||||
if (totalBytes > 0)
|
|
||||||
{
|
{
|
||||||
progress?.Report((double)bytesRead / totalBytes);
|
var rel = (string?)link.Attribute("rel") ?? string.Empty;
|
||||||
|
return rel.Contains(OpdsAcquisitionRel, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
DetermineSupportedFormat(link) != null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (acquisitionLink == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadHref = (string?)acquisitionLink.Attribute("href");
|
||||||
|
var format = DetermineSupportedFormat(acquisitionLink);
|
||||||
|
if (string.IsNullOrWhiteSpace(downloadHref) || string.IsNullOrWhiteSpace(format))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var title = entry.Element(AtomNamespace + "title")?.Value?.Trim();
|
||||||
|
var authors = entry.Elements(AtomNamespace + "author")
|
||||||
|
.Select(author => author.Element(AtomNamespace + "name")?.Value?.Trim())
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.Cast<string>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var coverHref = links
|
||||||
|
.FirstOrDefault(link => string.Equals((string?)link.Attribute("rel"), OpdsImageRel, StringComparison.OrdinalIgnoreCase))?
|
||||||
|
.Attribute("href")?.Value
|
||||||
|
?? links.FirstOrDefault(link => string.Equals((string?)link.Attribute("rel"), OpdsThumbnailRel, StringComparison.OrdinalIgnoreCase))?
|
||||||
|
.Attribute("href")?.Value;
|
||||||
|
|
||||||
|
string? coverUrl = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(coverHref))
|
||||||
|
{
|
||||||
|
coverUrl = TryToAbsoluteHttpUrl(coverHref);
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadUrl = TryToAbsoluteHttpUrl(downloadHref);
|
||||||
|
if (string.IsNullOrWhiteSpace(downloadUrl))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CalibreBook
|
||||||
|
{
|
||||||
|
Id = ExtractBookId(downloadHref) ?? ExtractBookId(coverHref) ?? Guid.NewGuid().ToString("N"),
|
||||||
|
Title = string.IsNullOrWhiteSpace(title) ? "Без названия" : title,
|
||||||
|
Author = authors.Count > 0 ? string.Join(", ", authors) : "Неизвестный автор",
|
||||||
|
Format = format,
|
||||||
|
CoverUrl = coverUrl,
|
||||||
|
DownloadUrl = downloadUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return filePath;
|
private async Task PopulateCoverImagesAsync(IEnumerable<CalibreBook> books)
|
||||||
|
{
|
||||||
|
var semaphore = new SemaphoreSlim(4);
|
||||||
|
var tasks = books.Select(async book =>
|
||||||
|
{
|
||||||
|
await semaphore.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await PopulateCoverImageAsync(book);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
semaphore.Release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PopulateCoverImageAsync(CalibreBook book)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(book.CoverUrl))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryCreateSupportedRemoteUri(book.CoverUrl, out var coverUri))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
book.CoverImage = await _coverCacheService.GetCoverAsync(book.CoverCacheKey);
|
||||||
|
if (book.CoverImage != null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
book.CoverImage = await _httpClient.GetByteArrayAsync(coverUri);
|
||||||
|
if (book.CoverImage != null && book.CoverImage.Length > 0)
|
||||||
|
{
|
||||||
|
await _coverCacheService.SetCoverAsync(book.CoverCacheKey, book.CoverImage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOpdsFeed(string responseBody)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var document = XDocument.Parse(responseBody);
|
||||||
|
return document.Root?.Name == AtomNamespace + "feed";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? DetermineSupportedFormat(XElement link)
|
||||||
|
{
|
||||||
|
var type = (string?)link.Attribute("type");
|
||||||
|
var title = (string?)link.Attribute("title");
|
||||||
|
var href = (string?)link.Attribute("href");
|
||||||
|
var candidates = new[] { type, title, href };
|
||||||
|
|
||||||
|
if (candidates.Any(candidate => candidate?.Contains("epub", StringComparison.OrdinalIgnoreCase) == true))
|
||||||
|
{
|
||||||
|
return "epub";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.Any(candidate =>
|
||||||
|
candidate?.Contains("fb2", StringComparison.OrdinalIgnoreCase) == true ||
|
||||||
|
candidate?.Contains("fictionbook", StringComparison.OrdinalIgnoreCase) == true))
|
||||||
|
{
|
||||||
|
return "fb2";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ToAbsoluteUrl(string relativeOrAbsoluteUrl)
|
||||||
|
{
|
||||||
|
var absoluteUrl = TryToAbsoluteHttpUrl(relativeOrAbsoluteUrl);
|
||||||
|
if (string.IsNullOrWhiteSpace(absoluteUrl))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Сервер Calibre вернул ссылку с неподдерживаемой схемой. BaseUrl: {_baseUrl}; Link: {relativeOrAbsoluteUrl}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return absoluteUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? TryToAbsoluteHttpUrl(string relativeOrAbsoluteUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(relativeOrAbsoluteUrl) || string.IsNullOrWhiteSpace(_baseUrl))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasExplicitUriScheme(relativeOrAbsoluteUrl))
|
||||||
|
{
|
||||||
|
return TryCreateSupportedRemoteUri(relativeOrAbsoluteUrl, out var absoluteUri)
|
||||||
|
? absoluteUri.ToString()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseUri = new Uri($"{_baseUrl.TrimEnd('/')}/");
|
||||||
|
var combinedUri = new Uri(baseUri, relativeOrAbsoluteUrl.TrimStart('/'));
|
||||||
|
return IsSupportedRemoteScheme(combinedUri) ? combinedUri.ToString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasExplicitUriScheme(string value)
|
||||||
|
{
|
||||||
|
return Regex.IsMatch(value, @"^[a-zA-Z][a-zA-Z0-9+.-]*:");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryCreateSupportedRemoteUri(string? value, out Uri uri)
|
||||||
|
{
|
||||||
|
if (Uri.TryCreate(value, UriKind.Absolute, out uri!))
|
||||||
|
{
|
||||||
|
return IsSupportedRemoteScheme(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
uri = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSupportedRemoteScheme(Uri uri)
|
||||||
|
{
|
||||||
|
return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOpdsDownloadUri(Uri uri)
|
||||||
|
{
|
||||||
|
return uri.AbsolutePath.Contains("/opds/download/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> ReadErrorSnippetAsync(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = Regex.Replace(body, @"\s+", " ").Trim();
|
||||||
|
return normalized.Length <= 180 ? normalized : normalized[..180];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractBookId(string? href)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(href))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var match = Regex.Match(href, @"/(?:opds/)?(?:download|cover)/(\d+)", RegexOptions.IgnoreCase);
|
||||||
|
return match.Success ? match.Groups[1].Value : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
8
BookReader/Services/IBookImportPickerService.cs
Normal file
8
BookReader/Services/IBookImportPickerService.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace BookReader.Services;
|
||||||
|
|
||||||
|
public sealed record SelectedBookFile(string OriginalFileName, string TempFilePath);
|
||||||
|
|
||||||
|
public interface IBookImportPickerService
|
||||||
|
{
|
||||||
|
Task<SelectedBookFile?> PickBookFileAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ public partial class BookshelfViewModel : BaseViewModel
|
|||||||
private readonly IDatabaseService _databaseService;
|
private readonly IDatabaseService _databaseService;
|
||||||
private readonly IBookParserService _bookParserService;
|
private readonly IBookParserService _bookParserService;
|
||||||
private readonly INavigationService _navigationService;
|
private readonly INavigationService _navigationService;
|
||||||
|
private readonly IBookImportPickerService _bookImportPickerService;
|
||||||
|
|
||||||
public ObservableCollection<Book> Books { get; } = new();
|
public ObservableCollection<Book> Books { get; } = new();
|
||||||
|
|
||||||
@@ -48,11 +49,13 @@ public partial class BookshelfViewModel : BaseViewModel
|
|||||||
public BookshelfViewModel(
|
public BookshelfViewModel(
|
||||||
IDatabaseService databaseService,
|
IDatabaseService databaseService,
|
||||||
IBookParserService bookParserService,
|
IBookParserService bookParserService,
|
||||||
INavigationService navigationService)
|
INavigationService navigationService,
|
||||||
|
IBookImportPickerService bookImportPickerService)
|
||||||
{
|
{
|
||||||
_databaseService = databaseService;
|
_databaseService = databaseService;
|
||||||
_bookParserService = bookParserService;
|
_bookParserService = bookParserService;
|
||||||
_navigationService = navigationService;
|
_navigationService = navigationService;
|
||||||
|
_bookImportPickerService = bookImportPickerService;
|
||||||
Title = "Библиотека";
|
Title = "Библиотека";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,25 +106,17 @@ public partial class BookshelfViewModel : BaseViewModel
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public async Task AddBookFromFileAsync()
|
public async Task AddBookFromFileAsync()
|
||||||
{
|
{
|
||||||
|
SelectedBookFile? selectedBookFile = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var customFileTypes = new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
|
selectedBookFile = await _bookImportPickerService.PickBookFileAsync();
|
||||||
{
|
if (selectedBookFile == null)
|
||||||
{ DevicePlatform.Android, new[] { "application/epub+zip", "application/x-fictionbook+xml", "application/octet-stream", "*/*" } }
|
|
||||||
});
|
|
||||||
|
|
||||||
var result = await FilePicker.Default.PickAsync(new PickOptions
|
|
||||||
{
|
|
||||||
PickerTitle = "Выберите книгу",
|
|
||||||
FileTypes = customFileTypes
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result == null)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var extension = Path.GetExtension(result.FileName).ToLowerInvariant();
|
var extension = Path.GetExtension(selectedBookFile.OriginalFileName).ToLowerInvariant();
|
||||||
if (extension != Constants.Files.EpubExtension && extension != Constants.Files.Fb2Extension)
|
if (extension != Constants.Files.EpubExtension && extension != Constants.Files.Fb2Extension)
|
||||||
{
|
{
|
||||||
await _navigationService.DisplayAlertAsync("Формат не поддерживается", "Сейчас можно добавить только EPUB и FB2.", "OK");
|
await _navigationService.DisplayAlertAsync("Формат не поддерживается", "Сейчас можно добавить только EPUB и FB2.", "OK");
|
||||||
@@ -131,24 +126,9 @@ public partial class BookshelfViewModel : BaseViewModel
|
|||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
StatusMessage = "Добавляю книгу...";
|
StatusMessage = "Добавляю книгу...";
|
||||||
|
|
||||||
using var sourceStream = await result.OpenReadAsync();
|
var book = await _bookParserService.ParseAndStoreBookAsync(selectedBookFile.TempFilePath, selectedBookFile.OriginalFileName);
|
||||||
var tempPath = Path.Combine(FileSystem.CacheDirectory, result.FileName);
|
|
||||||
using (var tempFileStream = File.Create(tempPath))
|
|
||||||
{
|
|
||||||
await sourceStream.CopyToAsync(tempFileStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
var book = await _bookParserService.ParseAndStoreBookAsync(tempPath, result.FileName);
|
|
||||||
Books.Insert(0, book);
|
Books.Insert(0, book);
|
||||||
RefreshLibraryState();
|
RefreshLibraryState();
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.Delete(tempPath);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -156,6 +136,7 @@ public partial class BookshelfViewModel : BaseViewModel
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
TryDeleteTempFile(selectedBookFile?.TempFilePath);
|
||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
StatusMessage = string.Empty;
|
StatusMessage = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -214,6 +195,25 @@ public partial class BookshelfViewModel : BaseViewModel
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public Task OpenCalibreLibraryAsync() => _navigationService.GoToAsync("//calibre");
|
public Task OpenCalibreLibraryAsync() => _navigationService.GoToAsync("//calibre");
|
||||||
|
|
||||||
|
private static void TryDeleteTempFile(string? tempFilePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tempFilePath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(tempFilePath))
|
||||||
|
{
|
||||||
|
File.Delete(tempFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void RefreshLibraryState()
|
private void RefreshLibraryState()
|
||||||
{
|
{
|
||||||
IsEmpty = Books.Count == 0;
|
IsEmpty = Books.Count == 0;
|
||||||
|
|||||||
@@ -68,7 +68,15 @@ public partial class CalibreLibraryViewModel : BaseViewModel
|
|||||||
|
|
||||||
if (IsConfigured)
|
if (IsConfigured)
|
||||||
{
|
{
|
||||||
_calibreWebService.Configure(url, username, password);
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
|
||||||
|
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||||
|
{
|
||||||
|
Books.Clear();
|
||||||
|
ConnectionErrorMessage = $"Сохранён некорректный адрес Calibre: {url}";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_calibreWebService.Configure(uri.ToString(), username, password);
|
||||||
await LoadBooksAsync();
|
await LoadBooksAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -226,3 +234,4 @@ public partial class CalibreLibraryViewModel : BaseViewModel
|
|||||||
OnPropertyChanged(nameof(HasBooks));
|
OnPropertyChanged(nameof(HasBooks));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,8 +129,17 @@ public partial class SettingsViewModel : BaseViewModel
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var success = await _calibreWebService.TestConnectionAsync(CalibreUrl, CalibreUsername, CalibrePassword);
|
var success = await _calibreWebService.TestConnectionAsync(CalibreUrl, CalibreUsername, CalibrePassword);
|
||||||
ConnectionStatus = success ? "Соединение установлено." : "Сервер ответил ошибкой или недоступен.";
|
if (success)
|
||||||
ConnectionStatusColor = success ? SuccessTone : DangerTone;
|
{
|
||||||
|
await PersistSettingsAsync(showNotification: false);
|
||||||
|
ConnectionStatus = "Соединение установлено. Настройки сохранены.";
|
||||||
|
ConnectionStatusColor = SuccessTone;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ConnectionStatus = "Сервер ответил ошибкой или недоступен.";
|
||||||
|
ConnectionStatusColor = DangerTone;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -236,3 +245,4 @@ public partial class SettingsViewModel : BaseViewModel
|
|||||||
_ => Constants.Reader.DefaultTheme
|
_ => Constants.Reader.DefaultTheme
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
IsRefreshing="{Binding IsRefreshing}"
|
IsRefreshing="{Binding IsRefreshing}"
|
||||||
Margin="0,0,0,24">
|
Margin="0,0,0,24">
|
||||||
<CollectionView ItemsSource="{Binding Books}"
|
<CollectionView ItemsSource="{Binding Books}"
|
||||||
|
IsVisible="{Binding HasConnectionError, Converter={StaticResource InvertedBoolConverter}}"
|
||||||
SelectionMode="None"
|
SelectionMode="None"
|
||||||
RemainingItemsThreshold="5"
|
RemainingItemsThreshold="5"
|
||||||
RemainingItemsThresholdReachedCommand="{Binding LoadMoreBooksCommand}">
|
RemainingItemsThresholdReachedCommand="{Binding LoadMoreBooksCommand}">
|
||||||
@@ -167,3 +168,4 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user