From 74482f9d3907319ac78f964bc3aa09ca1acd2e2c Mon Sep 17 00:00:00 2001 From: Phil Denhoff Date: Wed, 31 Jan 2024 01:58:32 -0800 Subject: [PATCH] wip: Edit assoc. authors for Book using combobox --- .../services/domain/book/service.rs | 6 + .../src/application/services/library/dto.rs | 10 +- .../application/services/library/service.rs | 112 +++++++-- .../libcalibre/src/domain/book/repository.rs | 2 + .../infrastructure/domain/book/repository.rs | 13 + src-tauri/libcalibre/src/lib.rs | 1 + src-tauri/src/book.rs | 10 +- src-tauri/src/libs/calibre/author.rs | 37 +++ src-tauri/src/libs/calibre/book.rs | 19 +- src-tauri/src/libs/calibre/mod.rs | 68 ++++-- .../src/libs/devices/calibre_ext_cache.rs | 2 +- src-tauri/src/libs/devices/external_drive.rs | 4 +- src-tauri/src/main.rs | 1 + src/bindings.ts | 11 +- src/components/atoms/BookAsCover.svelte | 2 +- src/lib/library/_types.ts | 5 +- src/lib/library/adapters/calibre.ts | 14 +- src/routes/+page.svelte | 7 +- src/routes/books/[slug]/+page.svelte | 228 ++++++++++++++---- 19 files changed, 444 insertions(+), 108 deletions(-) create mode 100644 src-tauri/src/libs/calibre/author.rs diff --git a/src-tauri/libcalibre/src/application/services/domain/book/service.rs b/src-tauri/libcalibre/src/application/services/domain/book/service.rs index f1b0e1a..6fef483 100644 --- a/src-tauri/libcalibre/src/application/services/domain/book/service.rs +++ b/src-tauri/libcalibre/src/application/services/domain/book/service.rs @@ -10,6 +10,7 @@ pub trait BookServiceTrait { fn update(&mut self, id: i32, dto: UpdateBookDto) -> Result; fn find_author_ids_by_book_id(&mut self, book_id: i32) -> Result, ()>; fn link_book_to_author(&mut self, book_id: i32, author_id: i32) -> Result<(), ()>; + fn unlink_book_from_author(&mut self, book_id: i32, author_id: i32) -> Result<(), ()>; } pub struct BookService { @@ -50,4 +51,9 @@ impl BookServiceTrait for BookService { self.book_repository .create_book_author_link(book_id, author_id) } + + fn unlink_book_from_author(&mut self, book_id: i32, author_id: i32) -> Result<(), ()> { + self.book_repository + .remove_book_author_link(book_id, author_id) + } } diff --git a/src-tauri/libcalibre/src/application/services/library/dto.rs b/src-tauri/libcalibre/src/application/services/library/dto.rs index 75ad7c8..e5ad9e4 100644 --- a/src-tauri/libcalibre/src/application/services/library/dto.rs +++ b/src-tauri/libcalibre/src/application/services/library/dto.rs @@ -1,6 +1,9 @@ use std::path::PathBuf; -use crate::application::services::domain::{author::dto::NewAuthorDto, book::dto::NewBookDto}; +use crate::application::services::domain::{ + author::dto::NewAuthorDto, + book::dto::{NewBookDto, UpdateBookDto}, +}; pub struct NewLibraryFileDto { pub path: PathBuf, @@ -9,6 +12,11 @@ pub struct NewLibraryFileDto { //pub mime_type: String, } +pub struct UpdateLibraryEntryDto { + pub book: UpdateBookDto, + pub author_id_list: Option>, +} + pub struct NewLibraryEntryDto { pub book: NewBookDto, pub authors: Vec, diff --git a/src-tauri/libcalibre/src/application/services/library/service.rs b/src-tauri/libcalibre/src/application/services/library/service.rs index 2bb9128..bef2772 100644 --- a/src-tauri/libcalibre/src/application/services/library/service.rs +++ b/src-tauri/libcalibre/src/application/services/library/service.rs @@ -16,7 +16,7 @@ use crate::domain::book::aggregate::BookWithAuthorsAndFiles; use crate::infrastructure::file_service::FileServiceTrait; use crate::{Book, BookFile}; -use super::dto::{NewLibraryEntryDto, NewLibraryFileDto}; +use super::dto::{NewLibraryEntryDto, NewLibraryFileDto, UpdateLibraryEntryDto}; #[derive(Debug)] pub enum LibSrvcError { @@ -95,7 +95,10 @@ where let primary_author = &author_list[0]; for author in &author_list { - let mut book_service = self.book_service.lock().map_err(|_| LibSrvcError::DatabaseLocked)?; + let mut book_service = self + .book_service + .lock() + .map_err(|_| LibSrvcError::DatabaseLocked)?; book_service .link_book_to_author(book.id, author.id) .map_err(|_| LibSrvcError::BookAuthorLinkFailed)?; @@ -104,7 +107,10 @@ where // 2. Create Directories for Author & Book // ====================================== let author_dir_name = { - let mut author_service = self.author_service.lock().map_err(|_| LibSrvcError::DatabaseLocked)?; + let mut author_service = self + .author_service + .lock() + .map_err(|_| LibSrvcError::DatabaseLocked)?; author_service.name_author_dir(primary_author) }; @@ -112,7 +118,10 @@ where let book_dir_relative_path = Path::new(&author_dir_name).join(&book_dir_name); { - let file_service = self.file_service.lock().map_err(|_| LibSrvcError::DatabaseLocked)?; + let file_service = self + .file_service + .lock() + .map_err(|_| LibSrvcError::DatabaseLocked)?; file_service.create_directory(Path::new(&author_dir_name).to_path_buf())?; file_service.create_directory(book_dir_relative_path.clone())?; } @@ -139,8 +148,14 @@ where // If a Cover Image exists, copy it to library let primary_file = &files[0]; { - let mut bfs = self.book_file_service.lock().map_err(|_| LibSrvcError::DatabaseLocked)?; - let fs = self.file_service.lock().map_err(|_| LibSrvcError::DatabaseLocked)?; + let mut bfs = self + .book_file_service + .lock() + .map_err(|_| LibSrvcError::DatabaseLocked)?; + let fs = self + .file_service + .lock() + .map_err(|_| LibSrvcError::DatabaseLocked)?; let cover_data = bfs.cover_img_data_from_path(primary_file.path.as_path())?; if let Some(cover_data) = cover_data { @@ -173,8 +188,13 @@ where &mut self, id: i32, ) -> Result> { - let mut book_service = self.book_service.lock().map_err(|_| LibSrvcError::DatabaseLocked)?; - let book = book_service.find_by_id(id).map_err(|_| LibSrvcError::NotFoundBook)?; + let mut book_service = self + .book_service + .lock() + .map_err(|_| LibSrvcError::DatabaseLocked)?; + let book = book_service + .find_by_id(id) + .map_err(|_| LibSrvcError::NotFoundBook)?; let author_ids = book_service .find_author_ids_by_book_id(id) @@ -183,7 +203,10 @@ where let authors: Vec = author_ids .into_iter() .map(|author_id| { - let mut author_service = self.author_service.lock().map_err(|_| LibSrvcError::DatabaseLocked)?; + let mut author_service = self + .author_service + .lock() + .map_err(|_| LibSrvcError::DatabaseLocked)?; match author_service.find_by_id(author_id) { Ok(Some(author)) => Ok(author), _ => Err(LibSrvcError::NotFoundAuthor), @@ -208,7 +231,10 @@ where } pub fn find_all(&mut self) -> Result, Box> { - let mut book_service = self.book_service.lock().map_err(|_| LibSrvcError::DatabaseLocked)?; + let mut book_service = self + .book_service + .lock() + .map_err(|_| LibSrvcError::DatabaseLocked)?; let books = book_service .all() .map_err(|_| LibSrvcError::DatabaseBookWriteFailed)?; @@ -222,7 +248,10 @@ where let authors: Vec = author_ids .into_iter() .map(|author_id| { - let mut author_service = self.author_service.lock().map_err(|_| LibSrvcError::DatabaseLocked)?; + let mut author_service = self + .author_service + .lock() + .map_err(|_| LibSrvcError::DatabaseLocked)?; match author_service.find_by_id(author_id) { Ok(Some(author)) => Ok(author), _ => Err(LibSrvcError::NotFoundAuthor), @@ -249,6 +278,51 @@ where Ok(book_list) } + pub fn update( + &mut self, + book_id: i32, + dto: UpdateLibraryEntryDto, + ) -> Result> { + { + let mut book_service = self + .book_service + .lock() + .map_err(|_| LibSrvcError::DatabaseLocked)?; + book_service + .update(book_id, dto.book) + .map_err(|_| LibSrvcError::DatabaseBookWriteFailed); + + let authors = book_service + .find_author_ids_by_book_id(book_id) + .map_err(|_| LibSrvcError::NotFoundBook)?; + authors + .iter() + .map(|&author_id| { + book_service + .unlink_book_from_author(book_id, author_id) + .map_err(|_| LibSrvcError::BookAuthorLinkFailed) + }) + .collect::>()?; + + match dto.author_id_list { + Some(author_id_list) => { + author_id_list + .iter() + .map(|author_id| { + let author_id_int = author_id.parse::().unwrap(); + book_service + .link_book_to_author(book_id, author_id_int) + .map_err(|_| LibSrvcError::BookAuthorLinkFailed) + }) + .collect::>()?; + } + None => {} + } + } + + self.find_book_with_authors(book_id) + } + fn create_authors( &mut self, authors: Vec, @@ -256,7 +330,10 @@ where let author_list = authors .into_iter() .map(|dto| { - let mut author_service = self.author_service.lock().map_err(|_| LibSrvcError::DatabaseLocked)?; + let mut author_service = self + .author_service + .lock() + .map_err(|_| LibSrvcError::DatabaseLocked)?; author_service .create(dto) .map_err(|_| LibSrvcError::DatabaseAuthorWriteFailed) @@ -310,8 +387,15 @@ where added_book } - fn set_book_path(&self, book_id: i32, book_dir_rel_path: PathBuf) -> Result { - let mut book_service = self.book_service.lock().map_err(|_| LibSrvcError::DatabaseLocked)?; + fn set_book_path( + &self, + book_id: i32, + book_dir_rel_path: PathBuf, + ) -> Result { + let mut book_service = self + .book_service + .lock() + .map_err(|_| LibSrvcError::DatabaseLocked)?; book_service .update( book_id, diff --git a/src-tauri/libcalibre/src/domain/book/repository.rs b/src-tauri/libcalibre/src/domain/book/repository.rs index d924a44..95ec96b 100644 --- a/src-tauri/libcalibre/src/domain/book/repository.rs +++ b/src-tauri/libcalibre/src/domain/book/repository.rs @@ -7,6 +7,8 @@ pub trait Repository { fn create(&mut self, book: &NewBook) -> Result; /// Link this book to an author fn create_book_author_link(&mut self, book_id: i32, author_id: i32) -> Result<(), ()>; + /// Unlink this book from an author + fn remove_book_author_link(&mut self, book_id: i32, author_id: i32) -> Result<(), ()>; /// Find one book by ID. fn find_by_id(&mut self, id: i32) -> Result; /// Find the IDs of all authors for a book diff --git a/src-tauri/libcalibre/src/infrastructure/domain/book/repository.rs b/src-tauri/libcalibre/src/infrastructure/domain/book/repository.rs index 08fa2f8..b3d6593 100644 --- a/src-tauri/libcalibre/src/infrastructure/domain/book/repository.rs +++ b/src-tauri/libcalibre/src/infrastructure/domain/book/repository.rs @@ -88,6 +88,19 @@ impl Repository for BookRepository { } } + fn remove_book_author_link(&mut self, book_id: i32, author_id: i32) -> Result<(), ()> { + use crate::schema::books_authors_link::dsl::*; + + let link = + diesel::delete(books_authors_link.filter(book.eq(book_id).and(author.eq(author_id)))) + .execute(&mut self.connection); + + match link { + Ok(_) => Ok(()), + Err(_) => Err(()), + } + } + fn find_author_ids_by_book_id(&mut self, book_id: i32) -> Result, ()> { use crate::schema::books_authors_link::dsl::*; diff --git a/src-tauri/libcalibre/src/lib.rs b/src-tauri/libcalibre/src/lib.rs index 9c39409..84e6eca 100644 --- a/src-tauri/libcalibre/src/lib.rs +++ b/src-tauri/libcalibre/src/lib.rs @@ -7,6 +7,7 @@ mod persistence; mod schema; pub mod util; +pub use domain::author::entity::Author; pub use domain::book::entity::Book; pub use domain::book_file::entity::BookFile; pub use domain::book::aggregate::BookWithAuthorsAndFiles; diff --git a/src-tauri/src/book.rs b/src-tauri/src/book.rs index 5783e25..71be5fb 100644 --- a/src-tauri/src/book.rs +++ b/src-tauri/src/book.rs @@ -37,10 +37,10 @@ pub enum BookFile { #[derive(Serialize, specta::Type, Deserialize, Clone)] pub struct LibraryBook { - pub title: String, - pub author_list: Vec, pub id: String, pub uuid: Option, + pub title: String, + pub author_list: Vec, pub sortable_title: Option, pub author_sort_lookup: Option>, @@ -50,9 +50,11 @@ pub struct LibraryBook { pub cover_image: Option, } -#[derive(Serialize, specta::Type)] +#[derive(Serialize, specta::Type, Deserialize, Clone)] pub struct LibraryAuthor { - // Define the fields of LibraryAuthor struct here + pub id: String, + pub name: String, + pub sortable_name: String, } #[derive(Serialize, Deserialize, specta::Type)] diff --git a/src-tauri/src/libs/calibre/author.rs b/src-tauri/src/libs/calibre/author.rs new file mode 100644 index 0000000..dc30d02 --- /dev/null +++ b/src-tauri/src/libs/calibre/author.rs @@ -0,0 +1,37 @@ +use std::sync::{Arc, Mutex}; + +use libcalibre::{ + application::services::domain::author::service::{AuthorService, AuthorServiceTrait}, + infrastructure::domain::author::repository::AuthorRepository, +}; + +use crate::book::LibraryAuthor; + +impl From<&libcalibre::Author> for LibraryAuthor { + fn from(author: &libcalibre::Author) -> Self { + LibraryAuthor { + id: author.id.to_string(), + name: author.name.clone(), + sortable_name: author.sort.clone().unwrap_or("".to_string()), + } + } +} + +pub fn list_all(library_root: String) -> Vec { + let database_path = libcalibre::util::get_db_path(&library_root); + match database_path { + None => vec![], + Some(database_path) => { + let author_repo = Box::new(AuthorRepository::new(&database_path)); + let author_service = Arc::new(Mutex::new(AuthorService::new(author_repo))); + + { + let mut guarded_as = author_service.lock().unwrap(); + match guarded_as.all() { + Ok(authors) => authors.iter().map(|a| LibraryAuthor::from(a)).collect(), + Err(_) => vec![], + } + } + } + } +} diff --git a/src-tauri/src/libs/calibre/book.rs b/src-tauri/src/libs/calibre/book.rs index 0b4758a..9c636d0 100644 --- a/src-tauri/src/libs/calibre/book.rs +++ b/src-tauri/src/libs/calibre/book.rs @@ -12,38 +12,39 @@ use libcalibre::{ file::service::{BookFileService, BookFileServiceTrait}, }, library::service::LibraryService, - }, - infrastructure::{ + }, infrastructure::{ domain::{ author::repository::AuthorRepository, book::repository::BookRepository, book_file::repository::BookFileRepository, }, file_service::FileServiceTrait, - }, + }, Author }; use crate::{ - book::{BookFile, LibraryBook, LocalFile, LocalOrRemote, LocalOrRemoteUrl}, + book::{BookFile, LibraryAuthor, LibraryBook, LocalFile, LocalOrRemote, LocalOrRemoteUrl}, libs::util, }; fn to_library_book( library_path: &String, book: &libcalibre::Book, - author_names: Vec, + author_list: Vec, file_list: Vec, ) -> LibraryBook { LibraryBook { title: book.title.clone(), - author_list: author_names.clone(), + author_list: author_list.iter().map(|a| { + LibraryAuthor::from(a) + }).collect(), id: book.id.to_string(), uuid: book.uuid.clone(), sortable_title: book.sort.clone(), author_sort_lookup: Some( - author_names + author_list .iter() - .map(|a| (a.clone(), a.clone())) + .map(|a| (a.name.clone(), a.sortable_name())) .collect::>(), ), @@ -119,7 +120,7 @@ pub fn list_all(library_root: String) -> Vec { let mut calibre_book = to_library_book( &library_root, &b.book, - b.authors.iter().map(|a| a.name.clone()).collect(), + b.authors.clone(), b.files.clone(), ); calibre_book.cover_image = book_cover_image(&library_root, &b.book); diff --git a/src-tauri/src/libs/calibre/mod.rs b/src-tauri/src/libs/calibre/mod.rs index 783b6dd..4d4a74f 100644 --- a/src-tauri/src/libs/calibre/mod.rs +++ b/src-tauri/src/libs/calibre/mod.rs @@ -6,6 +6,7 @@ use std::sync::Mutex; use crate::book::ImportableBookMetadata; use crate::book::ImportableBookType; +use crate::book::LibraryAuthor; use crate::book::LibraryBook; use crate::libs::file_formats::read_epub_metadata; @@ -22,6 +23,7 @@ use libcalibre::application::services::domain::file::service::BookFileService; use libcalibre::application::services::domain::file::service::BookFileServiceTrait; use libcalibre::application::services::library::dto::NewLibraryEntryDto; use libcalibre::application::services::library::dto::NewLibraryFileDto; +use libcalibre::application::services::library::dto::UpdateLibraryEntryDto; use libcalibre::application::services::library::service::LibraryService; use libcalibre::infrastructure::domain::author::repository::AuthorRepository; use libcalibre::infrastructure::domain::book::repository::BookRepository; @@ -30,6 +32,7 @@ use libcalibre::infrastructure::file_service::FileServiceTrait; use serde::Deserialize; use serde::Serialize; +mod author; mod book; pub mod models; pub mod schema; @@ -64,6 +67,12 @@ pub fn calibre_load_books_from_db(library_root: String) -> Vec { book::list_all(library_root) } +#[tauri::command] +#[specta::specta] +pub fn calibre_list_all_authors(library_root: String) -> Vec { + author::list_all(library_root) +} + #[tauri::command] #[specta::specta] pub fn check_file_importable(path_to_file: String) -> ImportableFile { @@ -186,34 +195,55 @@ pub fn add_book_to_db_by_metadata(library_path: String, md: ImportableBookMetada } } +#[derive(Serialize, Deserialize, specta::Type, Debug)] +pub struct BookUpdate { + pub author_id_list: Option>, + pub title: Option, + pub timestamp: Option, + pub publication_date: Option, + // pub tags: Option, + // pub ext_id_list: Option>, +} + #[tauri::command] #[specta::specta] -pub fn update_book(library_path: String, book_id: String, new_title: String) -> Result { +pub fn update_book(library_path: String, book_id: String, updates: BookUpdate) -> Result { let database_path = libcalibre::util::get_db_path(&library_path); match database_path { None => panic!("Could not find database at {}", library_path), Some(database_path) => { let book_repo = Box::new(BookRepository::new(&database_path)); + let author_repo = Box::new(AuthorRepository::new(&database_path)); + let book_file_repo = Box::new(BookFileRepository::new(&database_path)); let book_service = Arc::new(Mutex::new(BookService::new(book_repo))); - let book_service_guard = book_service.lock(); - - match book_service_guard { - Ok(mut book_service) => { - let book_id_int = book_id.parse::().unwrap(); - let result = book_service.update( - book_id_int, - UpdateBookDto { - title: Some(new_title.clone()), - ..UpdateBookDto::default() - }, - ); - result.map(|book| book.id) - } - Err(e) => { - panic!("Could not lock book service: {}", e); - } - } + let author_service = Arc::new(Mutex::new(AuthorService::new(author_repo))); + let book_file_service = Arc::new(Mutex::new(BookFileService::new(book_file_repo))); + let file_service = Arc::new(Mutex::new( + libcalibre::infrastructure::file_service::FileService::new(&library_path), + )); + + let mut library_service = LibraryService::new( + book_service, + author_service, + file_service, + book_file_service, + ); + + let book_id_int = book_id.parse::().unwrap(); + let result = library_service.update( + book_id_int, + UpdateLibraryEntryDto { + book: UpdateBookDto { + title: updates.title, + timestamp: updates.timestamp, + pubdate: updates.publication_date, + ..UpdateBookDto::default() + }, + author_id_list: updates.author_id_list, + }, + ); + result.map(|entry| entry.book.id).map_err(|_| ()) } } } diff --git a/src-tauri/src/libs/devices/calibre_ext_cache.rs b/src-tauri/src/libs/devices/calibre_ext_cache.rs index 83aa507..7e5dd32 100644 --- a/src-tauri/src/libs/devices/calibre_ext_cache.rs +++ b/src-tauri/src/libs/devices/calibre_ext_cache.rs @@ -130,7 +130,7 @@ pub fn item_from_library_book(book: &LibraryBook) -> Result { // Create folder for book's author on drive, copy book to folder let author_path = - create_folder_for_author(&self.path, book.author_list[0].clone()) + create_folder_for_author(&self.path, book.author_list[0].name.clone()) .or(Err("Failed to create author folder on external drive"))?; let file_path_on_drive = Path::new(&author_path).join(&filename); std::fs::copy(&file.path, file_path_on_drive.clone()) .expect("Could not copy file to library folder"); // Relative to the root of the drive - let file_rel_path = Path::new(&book.author_list[0].clone()).join(&filename); + let file_rel_path = Path::new(&book.author_list[0].name.clone()).join(&filename); item.lpath = file_rel_path.to_str().unwrap().to_string(); // Remove all items with the same UUID diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c47922a..ee35d11 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -26,6 +26,7 @@ fn run_tauri_backend() -> std::io::Result<()> { let specta_builder = tauri_specta::ts::builder() .commands(tauri_specta::collect_commands![ libs::calibre::calibre_load_books_from_db, + libs::calibre::calibre_list_all_authors, libs::calibre::get_importable_file_metadata, libs::calibre::check_file_importable, libs::calibre::add_book_to_db_by_metadata, diff --git a/src/bindings.ts b/src/bindings.ts index d0a1280..b275dff 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -4,6 +4,9 @@ async calibreLoadBooksFromDb(libraryRoot: string) : Promise { return await TAURI_INVOKE("plugin:tauri-specta|calibre_load_books_from_db", { libraryRoot }); }, +async calibreListAllAuthors(libraryRoot: string) : Promise { +return await TAURI_INVOKE("plugin:tauri-specta|calibre_list_all_authors", { libraryRoot }); +}, async getImportableFileMetadata(file: ImportableFile) : Promise { return await TAURI_INVOKE("plugin:tauri-specta|get_importable_file_metadata", { file }); }, @@ -13,9 +16,9 @@ return await TAURI_INVOKE("plugin:tauri-specta|check_file_importable", { pathToF async addBookToDbByMetadata(libraryPath: string, md: ImportableBookMetadata) : Promise { return await TAURI_INVOKE("plugin:tauri-specta|add_book_to_db_by_metadata", { libraryPath, md }); }, -async updateBook(libraryPath: string, bookId: string, newTitle: string) : Promise<__Result__> { +async updateBook(libraryPath: string, bookId: string, updates: BookUpdate) : Promise<__Result__> { try { - return { status: "ok", data: await TAURI_INVOKE("plugin:tauri-specta|update_book", { libraryPath, bookId, newTitle }) }; + return { status: "ok", data: await TAURI_INVOKE("plugin:tauri-specta|update_book", { libraryPath, bookId, updates }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; @@ -34,6 +37,7 @@ return await TAURI_INVOKE("plugin:tauri-specta|add_book_to_external_drive", { pa /** user-defined types **/ export type BookFile = { Local: LocalFile } | { Remote: RemoteFile } +export type BookUpdate = { author_id_list: string[] | null; title: string | null; timestamp: string | null; publication_date: string | null } export type CalibreClientConfig = { library_path: string } /** * Represents metadata for pre-import books, which have a very loose structure. @@ -58,7 +62,8 @@ path: string; publication_date: string | null; file_contains_cover: boolean } export type ImportableBookType = "EPUB" | "PDF" | "MOBI" export type ImportableFile = { path: string } -export type LibraryBook = { title: string; author_list: string[]; id: string; uuid: string | null; sortable_title: string | null; author_sort_lookup: { [key in string]: string } | null; file_list: BookFile[]; cover_image: LocalOrRemoteUrl | null } +export type LibraryAuthor = { id: string; name: string; sortable_name: string } +export type LibraryBook = { id: string; uuid: string | null; title: string; author_list: LibraryAuthor[]; sortable_title: string | null; author_sort_lookup: { [key in string]: string } | null; file_list: BookFile[]; cover_image: LocalOrRemoteUrl | null } export type LocalFile = { /** * The absolute path to the file, including extension. diff --git a/src/components/atoms/BookAsCover.svelte b/src/components/atoms/BookAsCover.svelte index 776415d..2461737 100644 --- a/src/components/atoms/BookAsCover.svelte +++ b/src/components/atoms/BookAsCover.svelte @@ -92,7 +92,7 @@ {/if} {shortenToChars(book.title, 50)} - {book.author_list.join(", ")} + {book.author_list.map(item => item.name).join(", ")} ; + listAuthors(): Promise; sendToDevice( book: LibraryBook, deviceOptions: { @@ -20,7 +23,7 @@ export type Library = { path: string; } ): Promise; - updateBook(bookId: string, updates: Partial): Promise; + updateBook(bookId: string, updates: BookUpdate): Promise; /** * Returns the path to the cover image for the book with the given ID. diff --git a/src/lib/library/adapters/calibre.ts b/src/lib/library/adapters/calibre.ts index e9f8bb5..733bb5c 100644 --- a/src/lib/library/adapters/calibre.ts +++ b/src/lib/library/adapters/calibre.ts @@ -14,7 +14,7 @@ import type { } from "../_types"; const genLocalCalibreClient = async ( - options: LocalConnectionOptions, + options: LocalConnectionOptions ): Promise => { const config = await commands.initClient(options.libraryPath); const bookCoverCache = new Map< @@ -35,7 +35,7 @@ const genLocalCalibreClient = async ( return { listBooks: async () => { const results = await commands.calibreLoadBooksFromDb( - config.library_path, + config.library_path ); results.forEach((book) => { @@ -58,6 +58,9 @@ const genLocalCalibreClient = async ( return results; }, + listAuthors() { + return commands.calibreListAllAuthors(config.library_path); + }, sendToDevice: async (book, deviceOptions) => { await commands.addBookToExternalDrive(deviceOptions.path, book); }, @@ -65,7 +68,7 @@ const genLocalCalibreClient = async ( await commands.updateBook( options.libraryPath, bookId, - updates.title ?? "", + updates ); }, getCoverPathForBook: (bookId) => { @@ -94,7 +97,7 @@ const genLocalCalibreClient = async ( }; const genRemoteCalibreClient = async ( - options: RemoteConnectionOptions, + options: RemoteConnectionOptions ): Promise => { // All remote clients are really Citadel clients... but for a certain kind of // library. In this case, Calibre. @@ -118,6 +121,9 @@ const genRemoteCalibreClient = async ( return res; }, + listAuthors() { + throw new Error("Not implemented"); + }, sendToDevice: () => { throw new Error("Not implemented"); }, diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index bced7e2..0466de1 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -87,13 +87,14 @@ search.length === 0 ? $books : any(book.author_list, (item) => - item.toLowerCase().includes(search.toLowerCase()), + item.name.toLowerCase().includes(search.toLowerCase()) || + item.sortable_name.toLowerCase().includes(search.toLowerCase()), ) || book.title.toLowerCase().includes(search.toLowerCase()), ) .filter((book) => book.title !== "" && book.author_list.length > 0) .toSorted((a, b) => { - const a_author = a.author_list.length > 0 ? a.author_list[0] : ""; - const b_author = b.author_list.length > 0 ? b.author_list[0] : ""; + const a_author = a.author_list.length > 0 ? a.author_list[0].sortable_name : "" + const b_author = b.author_list.length > 0 ? b.author_list[0].sortable_name : "" switch ($sortOrder) { case "name-asc": diff --git a/src/routes/books/[slug]/+page.svelte b/src/routes/books/[slug]/+page.svelte index 31922da..559fe03 100644 --- a/src/routes/books/[slug]/+page.svelte +++ b/src/routes/books/[slug]/+page.svelte @@ -3,15 +3,81 @@ import { onMount } from "svelte"; import { libraryClient } from "../../../stores/library"; import type { PageData } from "./$types"; - import type { LibraryBook } from "../../../bindings"; + import type { LibraryBook, LibraryAuthor } from "../../../bindings"; import { writable } from "svelte/store"; import { Button } from "$lib/components/ui/button"; + import { + createCombobox, + melt, + type ComboboxOptionProps, + } from "@melt-ui/svelte"; + import CheckIcon from "virtual:icons/f7/checkmark-alt"; + import ChevronUpIcon from "virtual:icons/f7/chevron-down"; + import ChevronDownIcon from "virtual:icons/f7/chevron-up"; + // import { Check, ChevronDown, ChevronUp } from 'lucide-svelte'; + // import { fly } from 'svelte/transition'; + + let authors: LibraryAuthor[] = [ + { + id: "awk", + name: "Author 1", + sortable_name: "Author 1", + }, + { + id: "qz", + name: "Author 2", + sortable_name: "Author 2", + }, + { + id: "9", + name: "Author 3", + sortable_name: "Author 3", + }, + ]; + + const toOption = ( + author: LibraryAuthor + ): ComboboxOptionProps => ({ + value: author, + label: author.name, + disabled: false, + }); + + const { + elements: { menu, input, option, label}, + states: { open, inputValue, touchedInput, selected }, + helpers: { isSelected }, + } = createCombobox({ + forceVisible: true, + multiple: true, + positioning: { + placement: "right", + strategy: "absolute", + }, + }); + + $: if ($selected) { + $inputValue = ""; + } + + $: filteredAuthors = $touchedInput + ? authors.filter(({ id, name }) => { + const normalizedInput = $inputValue.toLowerCase(); + return ( + id.toLowerCase().includes(normalizedInput) || + name.toLowerCase().includes(normalizedInput) + ); + }) + : authors; + export let data: PageData; let book: LibraryBook; let pageTitle: string; - $: pageTitle = `"${book?.title}" by ${book?.author_list.join(", ")}`; + $: pageTitle = `"${book?.title}" by ${book?.author_list + .map((item) => item.name) + .join(", ")}`; let metadata = writable({} as LibraryBook); @@ -25,7 +91,11 @@ const x = async (id: string) => { book = await getBookMatchingId(id); + authors = (await libraryClient().listAuthors()).toSorted((a, b) => + a.sortable_name.localeCompare(b.sortable_name) + ); metadata.set(book); + selected.set(book.author_list.map(toOption)); }; onMount(async () => { @@ -38,6 +108,9 @@ libraryClient().updateBook(book!.id.toString(), { title: (formData.get("title") as string | undefined) ?? book.title ?? "", + author_id_list: $selected?.map((author) => author.value.id) ?? [], + publication_date: null, + timestamp: null, }); invalidateAll(); x(data.id); @@ -46,57 +119,120 @@
{#if window.__TAURI__} - -

Editing {pageTitle}

- -
-
- - - - - - Sort fields are set automatically. -
-
- - - - - sorted) - .join(", ")} - /> - Sort fields are set automatically. -
- - -
+ +

Editing {pageTitle}

+ +
+
+ + + + + + Sort fields are set automatically. +
+
+
+ + +
+
+ {#if $selected} + {#each $selected as author} + {author.value.name} + {/each} + {/if} +
+ +
+ +
+ {#if $open} + + {:else} + + {/if} +
+
+
+ {#if $open} +
    + +
    + {#each filteredAuthors as author, index (index)} +
  • + {#if $isSelected(author)} +
    + +
    + {/if} +
    + {author.name} +
    +
  • + {:else} +
  • + No results found +
  • + {/each} +
    +
+ {/if} +
+ Sort fields are set automatically. +
+ + +
{:else} -

You cannot edit book metadata outside of the Citadel desktop app. For now!

+

+ You cannot edit book metadata outside of the Citadel desktop app. For now! +

{/if}