diff --git a/BookReader/MauiProgram.cs b/BookReader/MauiProgram.cs index da09af1..8cdc8c0 100644 --- a/BookReader/MauiProgram.cs +++ b/BookReader/MauiProgram.cs @@ -1,4 +1,5 @@ -using BookReader.Services; +using BookReader.Platforms.Android.Services; +using BookReader.Services; using BookReader.ViewModels; using BookReader.Views; using CommunityToolkit.Maui; @@ -26,24 +27,21 @@ public static class MauiProgram builder.Logging.AddDebug(); #endif - // Register Services (Singleton for shared state) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); - // HTTP Client for Calibre builder.Services.AddHttpClient(); - // Register ViewModels builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); - // Register Pages builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); @@ -51,4 +49,4 @@ public static class MauiProgram return builder.Build(); } -} \ No newline at end of file +} diff --git a/BookReader/Platforms/Android/MainActivity.cs b/BookReader/Platforms/Android/MainActivity.cs index 5fa7a89..668bd1c 100644 --- a/BookReader/Platforms/Android/MainActivity.cs +++ b/BookReader/Platforms/Android/MainActivity.cs @@ -1,6 +1,8 @@ using Android.App; +using Android.Content; using Android.Content.PM; using Android.OS; +using BookReader.Platforms.Android.Services; namespace BookReader; @@ -16,7 +18,12 @@ public class MainActivity : MauiAppCompatActivity { base.OnCreate(savedInstanceState); - // Keep screen on while reading Window?.AddFlags(Android.Views.WindowManagerFlags.KeepScreenOn); } -} \ No newline at end of file + + protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data) + { + base.OnActivityResult(requestCode, resultCode, data); + AndroidActivityResultRegistry.Dispatch(requestCode, resultCode, data); + } +} diff --git a/BookReader/Platforms/Android/Services/AndroidBookImportPickerService.cs b/BookReader/Platforms/Android/Services/AndroidBookImportPickerService.cs new file mode 100644 index 0000000..a8410c5 --- /dev/null +++ b/BookReader/Platforms/Android/Services/AndroidBookImportPickerService.cs @@ -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? 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 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 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 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"; + } +} diff --git a/BookReader/Services/CalibreWebService.cs b/BookReader/Services/CalibreWebService.cs index c169d4e..58b6fa0 100644 --- a/BookReader/Services/CalibreWebService.cs +++ b/BookReader/Services/CalibreWebService.cs @@ -1,15 +1,31 @@ using BookReader.Models; using Newtonsoft.Json.Linq; +using System.Net; using System.Net.Http.Headers; using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; namespace BookReader.Services; 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 ICoverCacheService _coverCacheService; private string _baseUrl = string.Empty; + private CalibreBackendKind _backendKind = CalibreBackendKind.Unknown; + + private enum CalibreBackendKind + { + Unknown, + ContentServerAjax, + Opds + } public CalibreWebService(HttpClient httpClient, ICoverCacheService coverCacheService) { @@ -20,7 +36,13 @@ public class CalibreWebService : ICalibreWebService 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)) { @@ -39,8 +61,8 @@ public class CalibreWebService : ICalibreWebService try { Configure(url, username, password); - var response = await _httpClient.GetAsync($"{_baseUrl}/ajax/search?query=&num=1"); - return response.IsSuccessStatusCode; + var backendKind = await DetectBackendAsync(forceRefresh: true); + return backendKind != CalibreBackendKind.Unknown; } catch { @@ -55,52 +77,209 @@ public class CalibreWebService : ICalibreWebService return Result>.Failure("Сначала укажите адрес сервера Calibre в настройках."); } - var books = new List(); - try { - var offset = page * pageSize; - 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 response = await _httpClient.GetStringAsync(url); - var json = JObject.Parse(response); - var bookIds = json["book_ids"]?.ToObject>() ?? new List(); - - var semaphore = new SemaphoreSlim(4); - var tasks = bookIds.Select(async bookId => + var backendKind = await DetectBackendAsync(); + return backendKind switch { - await semaphore.WaitAsync(); - try - { - return await LoadBookDataAsync(bookId); - } - finally - { - semaphore.Release(); - } - }); - - var results = await Task.WhenAll(tasks); - books.AddRange(results.Where(book => book != null)!); - - return Result>.Success(books); + CalibreBackendKind.ContentServerAjax => await GetBooksFromAjaxAsync(searchQuery, page, pageSize), + CalibreBackendKind.Opds => await GetBooksFromOpdsAsync(searchQuery, page, pageSize), + _ => Result>.Failure("Не удалось определить API сервера Calibre. Для calibre-web нужен доступный OPDS, для calibre-server — API /ajax.") + }; } catch (TaskCanceledException ex) { return Result>.Failure(new TimeoutException("Сервер Calibre отвечает слишком долго.", ex)); } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized || ex.StatusCode == HttpStatusCode.Forbidden) + { + return Result>.Failure("Сервер Calibre отклонил авторизацию. Проверьте логин и пароль."); + } catch (HttpRequestException ex) { - return Result>.Failure(new HttpRequestException("Не удалось подключиться к серверу Calibre.", ex)); + return Result>.Failure($"Не удалось загрузить каталог Calibre: {ex.Message}"); + } + catch (ArgumentException ex) + { + return Result>.Failure(ex.Message); + } + catch (InvalidOperationException ex) + { + return Result>.Failure(ex.Message); } catch (Exception ex) { - return Result>.Failure(new Exception("Не удалось загрузить каталог Calibre.", ex)); + return Result>.Failure($"Не удалось загрузить каталог Calibre: {ex.GetBaseException().Message}"); } } - private async Task LoadBookDataAsync(int bookId) + public async Task DownloadBookAsync(CalibreBook book, IProgress? 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 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>> GetBooksFromAjaxAsync(string? searchQuery, int page, int pageSize) + { + var books = new List(); + var offset = page * pageSize; + 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 response = await _httpClient.GetStringAsync(url); + var json = JObject.Parse(response); + var bookIds = json["book_ids"]?.ToObject>() ?? new List(); + + var semaphore = new SemaphoreSlim(4); + var tasks = bookIds.Select(async bookId => + { + await semaphore.WaitAsync(); + try + { + return await LoadBookDataFromAjaxAsync(bookId); + } + finally + { + semaphore.Release(); + } + }); + + var results = await Task.WhenAll(tasks); + books.AddRange(results.Where(book => book != null)!); + + return Result>.Success(books); + } + + private async Task>> GetBooksFromOpdsAsync(string? searchQuery, int page, int pageSize) + { + var requestUrl = string.IsNullOrWhiteSpace(searchQuery) + ? ToAbsoluteUrl("/opds/books/letter/00") + : ToAbsoluteUrl($"/opds/search/{Uri.EscapeDataString(searchQuery.Trim())}"); + + using var response = await _httpClient.GetAsync(requestUrl); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return Result>.Failure("На сервере calibre-web не найден каталог OPDS."); + } + + 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() + .ToList() ?? new List(); + + var pagedBooks = allBooks + .Skip(page * pageSize) + .Take(pageSize) + .ToList(); + + await PopulateCoverImagesAsync(pagedBooks); + return Result>.Success(pagedBooks); + } + + private async Task LoadBookDataFromAjaxAsync(int bookId) { try { @@ -130,21 +309,7 @@ public class CalibreWebService : ICalibreWebService DownloadUrl = $"{_baseUrl}/get/{supportedFormat}/{bookId}" }; - var cacheKey = $"cover_{bookId}"; - 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 - { - } - } - + await PopulateCoverImageAsync(calibreBook); return calibreBook; } catch @@ -153,37 +318,243 @@ public class CalibreWebService : ICalibreWebService } } - public async Task DownloadBookAsync(CalibreBook book, IProgress? progress = null) + private CalibreBook? ParseOpdsBook(XElement entry) { - 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(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) + try { - await fileStream.WriteAsync(buffer, 0, read); - bytesRead += read; - - if (totalBytes > 0) + var links = entry.Elements(AtomNamespace + "link").ToList(); + var acquisitionLink = links.FirstOrDefault(link => { - 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() + .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; + } + } + + private async Task PopulateCoverImagesAsync(IEnumerable 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; } - return filePath; + 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 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; } } + + + + diff --git a/BookReader/Services/IBookImportPickerService.cs b/BookReader/Services/IBookImportPickerService.cs new file mode 100644 index 0000000..8c8c9ce --- /dev/null +++ b/BookReader/Services/IBookImportPickerService.cs @@ -0,0 +1,8 @@ +namespace BookReader.Services; + +public sealed record SelectedBookFile(string OriginalFileName, string TempFilePath); + +public interface IBookImportPickerService +{ + Task PickBookFileAsync(CancellationToken cancellationToken = default); +} diff --git a/BookReader/ViewModels/BookshelfViewModel.cs b/BookReader/ViewModels/BookshelfViewModel.cs index fb0b534..e048a44 100644 --- a/BookReader/ViewModels/BookshelfViewModel.cs +++ b/BookReader/ViewModels/BookshelfViewModel.cs @@ -11,6 +11,7 @@ public partial class BookshelfViewModel : BaseViewModel private readonly IDatabaseService _databaseService; private readonly IBookParserService _bookParserService; private readonly INavigationService _navigationService; + private readonly IBookImportPickerService _bookImportPickerService; public ObservableCollection Books { get; } = new(); @@ -48,11 +49,13 @@ public partial class BookshelfViewModel : BaseViewModel public BookshelfViewModel( IDatabaseService databaseService, IBookParserService bookParserService, - INavigationService navigationService) + INavigationService navigationService, + IBookImportPickerService bookImportPickerService) { _databaseService = databaseService; _bookParserService = bookParserService; _navigationService = navigationService; + _bookImportPickerService = bookImportPickerService; Title = "Библиотека"; } @@ -103,25 +106,17 @@ public partial class BookshelfViewModel : BaseViewModel [RelayCommand] public async Task AddBookFromFileAsync() { + SelectedBookFile? selectedBookFile = null; + try { - var customFileTypes = new FilePickerFileType(new Dictionary> - { - { 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) + selectedBookFile = await _bookImportPickerService.PickBookFileAsync(); + if (selectedBookFile == null) { return; } - var extension = Path.GetExtension(result.FileName).ToLowerInvariant(); + var extension = Path.GetExtension(selectedBookFile.OriginalFileName).ToLowerInvariant(); if (extension != Constants.Files.EpubExtension && extension != Constants.Files.Fb2Extension) { await _navigationService.DisplayAlertAsync("Формат не поддерживается", "Сейчас можно добавить только EPUB и FB2.", "OK"); @@ -131,24 +126,9 @@ public partial class BookshelfViewModel : BaseViewModel IsBusy = true; StatusMessage = "Добавляю книгу..."; - using var sourceStream = await result.OpenReadAsync(); - 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); + var book = await _bookParserService.ParseAndStoreBookAsync(selectedBookFile.TempFilePath, selectedBookFile.OriginalFileName); Books.Insert(0, book); RefreshLibraryState(); - - try - { - File.Delete(tempPath); - } - catch - { - } } catch (Exception ex) { @@ -156,6 +136,7 @@ public partial class BookshelfViewModel : BaseViewModel } finally { + TryDeleteTempFile(selectedBookFile?.TempFilePath); IsBusy = false; StatusMessage = string.Empty; } @@ -214,6 +195,25 @@ public partial class BookshelfViewModel : BaseViewModel [RelayCommand] 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() { IsEmpty = Books.Count == 0; diff --git a/BookReader/ViewModels/CalibreLibraryViewModel.cs b/BookReader/ViewModels/CalibreLibraryViewModel.cs index 79557fd..e60a51d 100644 --- a/BookReader/ViewModels/CalibreLibraryViewModel.cs +++ b/BookReader/ViewModels/CalibreLibraryViewModel.cs @@ -68,7 +68,15 @@ public partial class CalibreLibraryViewModel : BaseViewModel 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(); } else @@ -226,3 +234,4 @@ public partial class CalibreLibraryViewModel : BaseViewModel OnPropertyChanged(nameof(HasBooks)); } } + diff --git a/BookReader/ViewModels/SettingsViewModel.cs b/BookReader/ViewModels/SettingsViewModel.cs index 18886db..d77ef23 100644 --- a/BookReader/ViewModels/SettingsViewModel.cs +++ b/BookReader/ViewModels/SettingsViewModel.cs @@ -129,8 +129,17 @@ public partial class SettingsViewModel : BaseViewModel try { var success = await _calibreWebService.TestConnectionAsync(CalibreUrl, CalibreUsername, CalibrePassword); - ConnectionStatus = success ? "Соединение установлено." : "Сервер ответил ошибкой или недоступен."; - ConnectionStatusColor = success ? SuccessTone : DangerTone; + if (success) + { + await PersistSettingsAsync(showNotification: false); + ConnectionStatus = "Соединение установлено. Настройки сохранены."; + ConnectionStatusColor = SuccessTone; + } + else + { + ConnectionStatus = "Сервер ответил ошибкой или недоступен."; + ConnectionStatusColor = DangerTone; + } } catch (Exception ex) { @@ -236,3 +245,4 @@ public partial class SettingsViewModel : BaseViewModel _ => Constants.Reader.DefaultTheme }; } + diff --git a/BookReader/Views/CalibreLibraryPage.xaml b/BookReader/Views/CalibreLibraryPage.xaml index a22438c..ee00008 100644 --- a/BookReader/Views/CalibreLibraryPage.xaml +++ b/BookReader/Views/CalibreLibraryPage.xaml @@ -76,6 +76,7 @@ IsRefreshing="{Binding IsRefreshing}" Margin="0,0,0,24"> @@ -167,3 +168,4 @@ +