diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 654cd2a..7081e8a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -20,13 +20,15 @@ When changing rust files, make sure to run `cargo fmt` to format the code and Next to this, ensure the code compiles and passes all tests by running: -- `cargo check` -- `cargo test` +- `cargo check --workspace` (or `cargo check` from workspace root) +- `cargo test --workspace` (or `cargo test` from workspace root) Or use: -- `devenv shell -- cargo check` -- `devenv shell -- cargo test` +- `devenv shell -- cargo check --workspace` +- `devenv shell -- cargo test --workspace` + +**Note:** The project uses a Rust workspace with members `src-tauri` and `src-tauri/calibre_db`. Run commands from the workspace root (`/workspaces/kikou`) to check and test all crates together, or use `--workspace` flag to ensure both crates are included. ## Frontend/Typescript Files @@ -60,9 +62,14 @@ Tests should be added first before the actual implementation. Rust tests can be executed via: -`cargo test --manifest-path=src-tauri/Cargo.toml` +`cargo test --workspace` Or use: -`devenv shell -- cargo test --manifest-path=src-tauri/Cargo.toml` +`devenv shell -- cargo test --workspace` + +To run tests for a specific crate: + +- `cargo test -p calibre_db` (calibre_db crate only) +- `cargo test --manifest-path=src-tauri/Cargo.toml` (src-tauri crate only) ## Code Style diff --git a/.github/instructions/calibre_db.instructions.md b/.github/instructions/calibre_db.instructions.md new file mode 100644 index 0000000..5f92bc3 --- /dev/null +++ b/.github/instructions/calibre_db.instructions.md @@ -0,0 +1,106 @@ +--- +applyTo: "src-tauri/calibre_db/**/*.rs" +--- + +# calibre_db Crate - Calibre SQLite Database Bindings + +This is a standalone, publishable Rust crate providing type-safe access to Calibre SQLite databases based on Calibre's database schema (from `third_party/calibre/src/calibre/db`). + +## Purpose + +Read and deserialize book metadata from Calibre's SQLite `metadata.db` format, supporting 10 core tables (books, authors, publishers, tags, series, data, comments, ratings, identifiers, languages) and their many-to-many relationships. + +## Key Modules + +- `lib.rs`: Public API (`CalibreDatabase::open()`, `get_book()`, `all_books()`) +- `models.rs`: Book, Author, Series, Tag, Identifier data structures with builder patterns +- `schema.rs`: Database connection management and schema validation +- `queries.rs`: Query functions for books and related metadata +- `error.rs`: Error types (DatabaseError, NotFound, InvalidData, IoError, SerializationError) + +## Development Guidelines + +- Query functions return `Result` or `Result>` for missing relations +- Use builder pattern (`with_authors()`, `with_tags()`, etc.) for complex object construction +- Handle missing relations gracefully (some books lack series, publishers, ratings) +- Always verify book IDs before querying relations in loops +- Parse timestamps using RFC 3339, fallback to Utc::now() +- Fetch complete book data in single operation for performance + +## Database Schema Notes + +### Languages Table + +The `books_languages_link` table uses a foreign key relationship to the `languages` table: + +```sql +CREATE TABLE languages ( + id INTEGER PRIMARY KEY, + lang_code TEXT NOT NULL +); + +CREATE TABLE books_languages_link ( + id INTEGER PRIMARY KEY, + book INTEGER NOT NULL, + lang_code INTEGER NOT NULL, + item_order INTEGER NOT NULL DEFAULT 0, + UNIQUE(book, lang_code) +); +``` + +Language codes are stored as references (foreign keys) to the `languages` table, not as direct text columns. When querying languages: + +- Join `books_languages_link` with `languages` to fetch language codes +- Use `ORDER BY item_order ASC` to maintain language order +- Handle cases where books have no associated languages + +## Testing + +- Unit tests in each module (models, error, schema, queries) +- Integration tests in `tests/integration_tests.rs` with real SQLite databases +- All tests must create temporary databases; no file I/O to real libraries +- Verify both presence and absence of optional relations + +## Build & Test + +``` +cargo check -p calibre_db # Verify compilation +cargo fmt # Format code (or cargo fmt -p calibre_db) +cargo clippy -p calibre_db # Lint (or run from calibre_db directory) +cargo test -p calibre_db # Run all tests +cargo test -p calibre_db --lib # Unit tests only +cargo test -p calibre_db --test integration_tests # Integration tests +``` + +Alternatively, run from the workspace root or use `--workspace` to check all crates: + +``` +cargo check --workspace +cargo test --workspace +cargo clippy --workspace +``` + +## Troubleshooting + +### Schema Verification + +If you encounter unexpected column errors or schema-related issues, ask the user for their Calibre database path, then verify the actual database schema with: + +```bash +sqlite3 ".schema TABLE_NAME" +``` + +Replace `` with the user's actual Calibre `metadata.db` file path and `TABLE_NAME` with the table being investigated (e.g., `books_languages_link`, `books`, `authors`). + +### Common Issues + +**"no such column" errors**: The database schema may differ from expectations. Request the user's database path and use the schema verification command above to inspect the actual column names and structure. + +**Language code retrieval failures**: Calibre uses a foreign key relationship between `books_languages_link` and `languages` tables. Ask the user for their database path and verify both table schemas: + +```bash +sqlite3 ".schema books_languages_link" +sqlite3 ".schema languages" +``` + +Ensure queries properly join these tables and handle cases where books have no associated languages. diff --git a/.github/instructions/error.instructions.md b/.github/instructions/error.instructions.md new file mode 100644 index 0000000..72f609b --- /dev/null +++ b/.github/instructions/error.instructions.md @@ -0,0 +1,95 @@ +--- +applyTo: "src-tauri/**/*.rs" +--- + +# Error Handling in Rust + +Consistent error handling across all Rust modules using enums for type safety and clarity. + +## Purpose + +Provide a unified error handling strategy that: + +- Prioritizes enum error types for domain-specific errors +- Ensures Tauri commands return the top-level `AppError` type for IPC communication +- Uses specific error enums for internal library functions +- Maintains clear error boundaries between modules + +## Error Hierarchy + +### Tauri Commands Layer + +Tauri commands should always return `Result` to ensure proper serialization for IPC: + +```rust +#[tauri::command] +async fn fetch_book(lib: State>, id: u32) -> Result { + lib.get_book(id).await +} +``` + +### Internal Library Layer + +Internal functions should use specific error enums for their domain: + +```rust +pub enum LibraryError { + BookNotFound(u32), + InvalidPath(String), + DatabaseFailure(String), +} + +pub fn get_book_internal(id: u32) -> Result { + // Implementation +} +``` + +### AppError - Top-Level Error Type + +The unified error enum for converting all domain-specific errors to IPC-compatible format. + +**Variants:** + +- `LibraryError(String)`: General library operation failures (path validation, initialization) +- `BookNotFound(String)`: Specific book not found by ID or query +- `DatabaseError(String)`: Underlying SQLite or database failures +- `IoError(String)`: File system errors +- `ValidationError(String)`: Invalid input or data validation issues +- `ArchiveError(String)`: Archive/compression operation failures + +## Error Conversion + +Implement `From` traits to convert domain-specific errors to `AppError`: + +```rust +impl From for AppError { + fn from(err: LibraryError) -> Self { + match err { + LibraryError::BookNotFound(id) => AppError::BookNotFound(format!("Book {} not found", id)), + LibraryError::InvalidPath(p) => AppError::ValidationError(format!("Invalid path: {}", p)), + LibraryError::DatabaseFailure(e) => AppError::DatabaseError(e), + } + } +} + +impl From for AppError { + fn from(err: std::io::Error) -> Self { + AppError::IoError(err.to_string()) + } +} + +impl From for AppError { + fn from(err: serde_json::Error) -> Self { + AppError::ValidationError(err.to_string()) + } +} +``` + +## Guidelines + +- **Prioritize enums**: Use specific error enums for internal operations (e.g., `LibraryError`, `ArchiveError`) +- **Tauri boundary**: Always return `AppError` from Tauri commands +- **Error conversion**: Implement `From` traits to bridge domain-specific errors to `AppError` +- **Descriptive messages**: Include context in error messages for debugging +- **No panics**: Return errors instead of panicking in library code +- **Result types**: Use `Result` consistently throughout the codebase diff --git a/.github/instructions/library.calibre.instructions.md b/.github/instructions/library.calibre.instructions.md new file mode 100644 index 0000000..930b788 --- /dev/null +++ b/.github/instructions/library.calibre.instructions.md @@ -0,0 +1,90 @@ +--- +applyTo: "src-tauri/src/library/calibre.rs" +--- + +# Why `unsafe impl Send + Sync` is Safe and Necessary + +## The Problem + +`CalibreLibrary` wraps `Arc`, which internally contains `rusqlite::Connection`. While `rusqlite::Connection` is thread-safe, Rust's type system cannot automatically verify this because it uses `RefCell` internally for statement caching. + +The compiler requires `Send + Sync` for any type used across async task boundaries, but cannot derive these traits automatically from `Connection`. + +## The Solution: `unsafe impl` + +```rust +// SAFETY: CalibreDatabase wraps rusqlite::Connection, which is thread-safe. +// rusqlite uses SQLite's built-in locking mechanisms to ensure safe concurrent access. +// All database operations in this crate are read-only, preventing data races. +// The Arc pattern safely shares the connection across async tasks. +unsafe impl Send for CalibreLibrary {} +unsafe impl Sync for CalibreLibrary {} +``` + +## Why This Is Safe + +### 1. rusqlite::Connection is Thread-Safe + +- rusqlite wraps SQLite's C library, which provides thread-safe database access +- SQLite uses internal mutexes for serialization +- The Rust wrapper correctly exposes these guarantees +- See: https://www.sqlite.org/threadsafe.html + +### 2. All Operations Are Read-Only + +- The calibre_db crate only queries the database +- No mutations occur +- No shared mutable state across threads +- Data races are impossible with read-only access + +### 3. Arc Provides Safe Sharing + +- `Arc` is always `Send + Sync` if `T` is `Send + Sync` +- Atomic reference counting handles concurrent access safely +- The Arc itself is allocated on the heap with stable address +- No use-after-free or double-free can occur + +### 4. Compiler Verification Still Applies + +While marked `unsafe impl`, the actual usage is verified as safe: + +- Closures capturing `self` cannot violate Send/Sync requirements +- The async runtime cannot move tasks between threads unsafely +- Tauri's event loop enforces single-threaded execution where needed + +## Why Not Other Approaches? + +### Approach: tokio::task::spawn_blocking + +- Would require copying all data into blocking tasks +- Unnecessary performance overhead for read-only operations +- Adds complexity without benefit + +### Approach: Don't make it async + +- Violates the trait definition which requires async +- Cannot integrate properly with Tauri's async runtime +- Blocks on database I/O instead of yielding + +### Approach: RwLock or Mutex wrapper + +- rusqlite::Connection already handles internal locking +- Adding another lock layer creates unnecessary contention +- Defeats the purpose of SQLite's built-in concurrency + +## When This Pattern Is Appropriate + +Use `unsafe impl Send + Sync` when: + +1. Wrapping a known thread-safe C/FFI library (like rusqlite) +2. All operations are read-only or the library handles synchronization +3. The safety invariants are clearly documented +4. The usage is verified as safe through design + +This is a common and accepted pattern in Rust for FFI bindings. + +## References + +- Rust nomicon on FFI and thread safety: https://doc.rust-lang.org/nomicon/ffi.html +- rusqlite documentation: https://docs.rs/rusqlite/ +- Tokio task blocking: https://tokio.rs/tokio/tutorial/select#cancellation diff --git a/.github/instructions/library.instructions.md b/.github/instructions/library.instructions.md new file mode 100644 index 0000000..73aeb50 --- /dev/null +++ b/.github/instructions/library.instructions.md @@ -0,0 +1,186 @@ +--- +applyTo: "src-tauri/src/library/**/*.rs" +--- + +# Library Module - Implementation-Agnostic Library Access + +The library module provides a unified interface for accessing book library metadata from different sources (Calibre, Kobo, Amazon, etc.) through URI-based routing and a trait-based abstraction. + +## Architecture + +### Design Principle + +The module is designed to be **implementation-agnostic**. The frontend communicates library requests through URIs with schemes that indicate the implementation type (e.g., `calibre:///path/to/library`). The backend parses these URIs and manages the appropriate library implementation transparently. + +### Key Components + +- `base.rs`: `BookLibrary` trait defining the interface all implementations must provide +- `calibre.rs`: Calibre-specific implementation of `BookLibrary` +- `commands.rs`: Tauri command handlers that parse URIs and delegate to implementations +- `mod.rs`: Module exports +- `LibraryState`: Manages the currently open library (trait object) in application state + +### URI Scheme Format + +Libraries are identified by URIs with implementation-specific schemes: + +``` +calibre:///absolute/path/to/library +calibre://relative/path/to/library +``` + +The scheme determines which implementation to use; the path portion is passed to that implementation. + +## BookLibrary Trait + +The `BookLibrary` trait defines the interface all library implementations must provide: + +```rust +#[async_trait] +pub trait BookLibrary: Send + Sync { + async fn get_all_books(&self) -> Result, AppError>; + async fn get_book(&self, book_id: u32) -> Result; + async fn get_book_count(&self) -> Result; + fn clone_box(&self) -> Box; +} +``` + +### Key Design Patterns + +- **Async-first**: All methods use `async/await` for non-blocking I/O +- **Thread-safe**: Require `Send + Sync` for use across async boundaries +- **Arc wrapper**: Share database connections across async tasks safely +- **Error conversion**: Convert backend-specific errors to `AppError` +- **Polymorphic cloning**: `clone_box()` allows trait objects to be cloned + +## Tauri Commands + +All library commands are prefixed with `library_` for clarity and follow a consistent pattern: + +### `library_open(uri: String) -> Result<(), String>` + +Opens a library from the specified URI and stores it in application state. + +**Example frontend usage:** + +```typescript +const libraryPath = "/home/user/.local/share/calibre/"; +await invoke("library_open", { uri: `calibre://${libraryPath}` }); +``` + +### `library_get_all_books() -> Result, String>` + +Returns all books from the currently open library. The library must be opened first via `library_open`. + +### `library_get_book(book_id: u32) -> Result` + +Returns a single book with the specified ID from the currently open library. + +### `library_get_book_count() -> Result` + +Returns the total count of books in the currently open library. + +## State Management + +- `LibraryState` holds an `Arc>>>` +- The `Arc` allows safe sharing across async tasks +- The `Mutex` serializes access to prevent data races +- The `Option` indicates whether a library is currently open +- The trait object (`Box`) stores any implementation + +## Send + Sync Implementation + +When implementing `BookLibrary` for thread-safe resources, implementations may need to declare `unsafe impl Send + Sync`. This is required when: + +1. The underlying resource provides thread-safe guarantees (SQLite, etc.) +2. Operations are read-only or properly synchronized +3. Reference counting (Arc) is used for safe sharing +4. No mutable state is exposed across thread boundaries + +For detailed information about `unsafe impl Send + Sync` and its justification, see `library.calibre.instructions.md` for the Calibre implementation example. + +## Adding New Library Implementations + +To add support for a new library type: + +1. Create a new module (e.g., `kobo.rs`) implementing the `BookLibrary` trait +2. Implement required methods: + - `async fn get_all_books(&self) -> Result, AppError>` + - `async fn get_book(&self, book_id: u32) -> Result` + - `async fn get_book_count(&self) -> Result` + - `fn clone_box(&self) -> Box` +3. Add necessary trait implementations (Send, Sync, Clone, etc.) +4. Add a variant to the `LibraryUri` enum in `commands.rs` +5. Update `LibraryUri::parse()` to recognize the new URI scheme +6. Update the match statement in `library_open` to instantiate your implementation +7. Add your module to `mod.rs` and export from `base.rs` + +Example implementation steps: + +```rust +// In kobo.rs +#[derive(Clone)] +pub struct KoboLibrary { + db: Arc, +} + +impl KoboLibrary { + pub fn new(library_path: PathBuf) -> Result { + // Implementation here + } +} + +unsafe impl Send for KoboLibrary {} +unsafe impl Sync for KoboLibrary {} + +#[async_trait] +impl BookLibrary for KoboLibrary { + async fn get_all_books(&self) -> Result, AppError> { + // Implementation + } + // ... other methods +} + +// In commands.rs LibraryUri::parse() +if let Some(path) = uri.strip_prefix("kobo://") { + Ok(LibraryUri::Kobo(PathBuf::from(path))) +} + +// In library_open match +LibraryUri::Kobo(path) => { + let kobo_library = KoboLibrary::new(path).map_err(|e| e.to_string())?; + Box::new(kobo_library) +} +``` + +## Error Handling + +Error handling follows the unified strategy defined in `error.instructions.md`. Key points for the library module: + +- **Tauri commands** return `Result` for proper serialization across IPC +- **Internal functions** use domain-specific error enums (e.g., `LibraryError`) for type safety +- **Error conversion** implements `From` traits to convert internal errors to `AppError` +- **Invalid URIs** are rejected at parse time with descriptive `ValidationError` messages +- **Missing libraries** should return `LibraryError` variants +- **Library not open** conditions should return appropriate `LibraryError` or `ValidationError` + +For detailed error handling patterns, see `error.instructions.md`. + +## Testing Guidelines + +- Test each implementation's `BookLibrary` trait methods independently +- Test URI parsing for valid and invalid formats +- Test state transitions (open, query, close) +- Verify error handling for missing libraries and invalid paths +- Use temporary directories for file-based library tests +- Never require external services or real libraries in tests +- Verify thread-safety with concurrent access patterns if needed + +## Code Organization + +Keep the module organized: + +- Each library implementation in its own file +- Shared types and trait in `base.rs` +- Commands layer in `commands.rs` +- Export public API through `mod.rs` diff --git a/.github/instructions/ts.tests.instructions.md b/.github/instructions/ts.tests.instructions.md index b15bdd0..efd53ad 100644 --- a/.github/instructions/ts.tests.instructions.md +++ b/.github/instructions/ts.tests.instructions.md @@ -1,15 +1,93 @@ --- -applyTo: - - "**/*.test.tsx" - - "**/*.test.ts" - - "**/*.spec.tsx" - - "**/*.spec.ts" - - "**/__tests__/**/*.tsx" - - "**/__tests__/**/*.ts" +applyTo: "**/*.test.tsx,**/*.test.ts" --- # Frontend Tests -- Prefer using `getByTestId` (or `queryByTestId`, etc.) over `getByText` for selecting elements in frontend tests. This ensures selectors are robust against UI text changes and localization. +## React act() Warnings + +**CRITICAL: All React `act()` warnings MUST be addressed before committing code.** + +When testing React components, any code that causes state updates must be wrapped in `act()` to ensure tests accurately reflect how the component behaves in the browser. + +### Common Scenarios Requiring act() + +1. **Timer Advances (jest.advanceTimersByTime, jest.runAllTimers, etc.)** + + ```typescript + import { act } from "@testing-library/react"; + + act(() => { + jest.advanceTimersByTime(1000); + }); + ``` + +2. **Manual State Updates in Tests** + + ```typescript + act(() => { + // Code that triggers state updates + someFunction(); + }); + ``` + +3. **Cleanup with Pending Timers** + ```typescript + afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); + }); + ``` + +### Why This Matters + +- Ensures tests accurately simulate browser behavior +- Prevents flaky tests and race conditions +- Makes test assertions reliable and deterministic +- React requires this for proper testing of concurrent features + +**If you see "An update to [Component] inside a test was not wrapped in act(...)" warnings:** + +1. Identify what's causing the state update (timers, async operations, etc.) +2. Wrap that code in `act()` +3. Re-run tests to verify warnings are gone + +## Test ID Selection + +- Prefer using `getByTestId` (or `queryByTestId`, `findByTestId`, etc.) over `getByText` for selecting elements in frontend tests. This ensures selectors are robust against UI text changes and localization. - Only use `getByText` when there is no reasonable alternative (e.g. for verifying visible text content). - Always add a `data-testid` attribute to important elements/components that need to be targeted in tests. + +## Content Assertions + +- When asserting text content within an element retrieved via `getByTestId`, check the element's content against **test data variables** rather than hardcoded strings. +- Use test data (like `mockBook.title`) to verify that rendered content matches the expected data. + +**Good:** + +```typescript +const element = screen.getByTestId("book-title"); +expect(element).toHaveTextContent(mockBook.title); +``` + +**Avoid:** + +```typescript +const element = screen.getByTestId("book-title"); +expect(element).toHaveTextContent("Test Book"); +``` + +**Also Good (when text is meant to be static UI):** + +```typescript +expect(screen.getByTestId("error-message")).toBeInTheDocument(); +``` + +## Guidelines + +- Define test data (mocks, fixtures) as constants at the top of test files +- Reference these constants in assertions instead of hardcoding strings +- This makes tests maintainable: when test data changes, assertions automatically reflect those changes +- Reduces test brittleness and improves clarity about what is being tested diff --git a/.github/instructions/typescript.typing.instructions.md b/.github/instructions/typescript.typing.instructions.md new file mode 100644 index 0000000..a997752 --- /dev/null +++ b/.github/instructions/typescript.typing.instructions.md @@ -0,0 +1,192 @@ +--- +applyTo: "**/*.{ts,tsx}" +--- + +# TypeScript Typing Standards + +Ensure strict typing across all TypeScript files to catch errors at compile time and improve code maintainability. + +## Core Principles + +- **No implicit `any`**: Never use implicit `any` types. Always explicitly declare types or infer them correctly. +- **Explicit return types**: All functions and methods must have explicit return type annotations. +- **Explicit parameter types**: All function parameters must be explicitly typed. +- **No `any` or `unknown`**: Avoid `any` and `unknown` types unless there is an explicit, documented reason. +- **Strict mode**: Ensure `strict: true` is enabled in `tsconfig.json`. + +## Guidelines + +### Function Declarations + +```typescript +// Good - Explicit parameter and return types +function getBookTitle(book: Book): string { + return book.title; +} + +// Bad - Implicit any +function getBookTitle(book) { + return book.title; +} + +// Bad - No return type +function getBookTitle(book: Book) { + return book.title; +} +``` + +### Object/Interface Definitions + +```typescript +// Good - Explicit typed object +interface SearchQuery { + field: string; + value: string; + isRegex?: boolean; +} + +// Bad - Implicit any +const query = { + field: "title", + value: "test", +}; +``` + +### useState and State Management + +```typescript +// Good - Explicit type parameter +const [books, setBooks] = useState([]); +const [sortColumn, setSortColumn] = useState(null); + +// Bad - Implicit any +const [books, setBooks] = useState([]); +``` + +### Event Handlers + +```typescript +// Good - Explicit event type +const handleClick = (event: React.MouseEvent): void => { + // Implementation +}; + +// Bad - Implicit any +const handleClick = (event) => { + // Implementation +}; +``` + +### Callback Functions + +```typescript +// Good - Explicit parameter and return types +const handleToggle = (columnId: string): void => { + onToggleColumnVisibility(columnId); +}; + +// Bad - No types +const handleToggle = (columnId) => { + onToggleColumnVisibility(columnId); +}; +``` + +### useMemo and useCallback + +```typescript +// Good - Explicit return type +const filteredBooks = useMemo( + (): Book[] => books.filter((book) => matchesSearch(book, query)), + [books, query], +); + +const handleSort = useCallback((columnId: string): void => { + // Implementation +}, []); + +// Bad - No return type +const filteredBooks = useMemo( + () => books.filter((book) => matchesSearch(book, query)), + [books, query], +); +``` + +### Union and Optional Types + +```typescript +// Good - Explicit union type +type SortDirection = "asc" | "desc" | null; +const [sortDirection, setSortDirection] = useState(null); + +// Good - Optional with explicit type +const error: string | null = null; +const handleError = (message?: string): void => {}; + +// Bad - Implicit any in optional +const handleError = (message?) => {}; +``` + +### Generic Types + +```typescript +// Good - Explicit generic parameter +function parseQuery(data: string): Record { + // Implementation +} + +// Bad - Missing generic parameter +function parseQuery(data: string) { + // Implementation +} +``` + +### Array Types + +```typescript +// Good - Explicit array type +const books: Book[] = []; +const tags: string[] = []; + +// Also good - Alternative syntax +const books: Array = []; + +// Bad - Implicit any +const books = []; +``` + +### Ref Types + +```typescript +// Good - Explicit ref type +const resizingColumn = useRef(null); +const startX = useRef(0); + +// Bad - Implicit any +const resizingColumn = useRef(null); +``` + +## When `any` or `unknown` is Acceptable + +Only use `any` or `unknown` when: + +1. Working with truly dynamic external data (e.g., JSON from API without schema) +2. Integrating with untyped third-party libraries +3. There is an explicit code comment explaining why +4. The situation is temporary and there is a task to fix it + +**Always document with a comment:** + +```typescript +// TODO: Replace with proper type once API schema is defined +const data: any = response.data; + +// External library lacks TypeScript support +const result: unknown = externalLibrary.process(input); +``` + +## Verification + +- Run `pnpm build` to verify TypeScript compilation +- Run `pnpm lint` to check for type issues +- Ensure no TypeScript errors appear in the editor (red squiggles) +- Use `tsc --noEmit` to do a full type check without emitting files diff --git a/.github/workflows/cargo.yml b/.github/workflows/cargo.yml index dc44b55..5bd928f 100644 --- a/.github/workflows/cargo.yml +++ b/.github/workflows/cargo.yml @@ -28,6 +28,8 @@ jobs: src-tauri/**/Cargo.toml src-tauri/**/Cargo.lock .github/workflows/cargo.yml + Cargo.toml + Cargo.lock check: needs: detect-changes @@ -62,7 +64,7 @@ jobs: Start-Process -FilePath "MicrosoftEdgeWebview2Setup.exe" -ArgumentList "/silent", "/install" -Wait shell: powershell - name: Run cargo check - run: cargo check --manifest-path=src-tauri/Cargo.toml + run: cargo check --workspace test: needs: detect-changes @@ -102,9 +104,9 @@ jobs: - name: Run cargo test run: | if [ "${{ matrix.platform }}" = "windows-latest" ]; then - cargo test --manifest-path=src-tauri/Cargo.toml --lib + cargo test --lib else - cargo test --manifest-path=src-tauri/Cargo.toml + cargo test fi shell: bash env: @@ -144,7 +146,7 @@ jobs: Start-Process -FilePath "MicrosoftEdgeWebview2Setup.exe" -ArgumentList "/silent", "/install" -Wait shell: powershell - name: Run cargo build - run: cargo build --release --manifest-path=src-tauri/Cargo.toml + run: cargo build --release clippy: needs: detect-changes @@ -169,4 +171,4 @@ jobs: sudo apt-get update sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libsoup-3.0-dev libjavascriptcoregtk-4.1-dev - name: Run cargo clippy - run: cargo clippy --manifest-path=src-tauri/Cargo.toml --all-targets --all-features -- -D warnings + run: cargo clippy --all-targets --all-features -- -D warnings diff --git a/.gitignore b/.gitignore index 143f446..0f3fe85 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ out tmp coverage result +target diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d5a0673 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third_party/calibre"] + path = third_party/calibre + url = https://github.com/kovidgoyal/calibre.git diff --git a/src-tauri/Cargo.lock b/Cargo.lock similarity index 88% rename from src-tauri/Cargo.lock rename to Cargo.lock index 6dde36d..0a8d034 100644 --- a/src-tauri/Cargo.lock +++ b/Cargo.lock @@ -6,12 +6,15 @@ version = 4 name = "Kikou" version = "0.1.0" dependencies = [ + "async-trait", "base64 0.22.1", + "calibre_db", "log", "notify", "once_cell", "quick-xml 0.31.0", "rayon", + "rusqlite", "serde", "serde-xml-rs", "serde_json", @@ -20,18 +23,10 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-log", "tempfile", + "tokio", "zip", ] -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -60,11 +55,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -172,7 +179,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -183,7 +190,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -221,21 +228,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link 0.2.0", -] - [[package]] name = "base64" version = "0.21.7" @@ -256,11 +248,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -295,11 +287,11 @@ dependencies = [ [[package]] name = "block2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.2", + "objc2 0.6.3", ] [[package]] @@ -322,7 +314,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -387,9 +379,9 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.23.2" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" [[package]] name = "byteorder" @@ -421,7 +413,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "cairo-sys-rs", "glib", "libc", @@ -440,11 +432,25 @@ dependencies = [ "system-deps", ] +[[package]] +name = "calibre_db" +version = "0.1.0" +dependencies = [ + "chrono", + "futures", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "camino" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1de8bc0aa9e9385ceb3bf0c152e3a9b9544f6c4a912c8ae504e80c1f0368603" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" dependencies = [ "serde_core", ] @@ -479,14 +485,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" dependencies = [ "serde", - "toml 0.9.7", + "toml 0.9.8", ] [[package]] name = "cc" -version = "1.2.39" +version = "1.2.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" +checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" dependencies = [ "find-msvc-tools", "jobserver", @@ -523,9 +529,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -540,9 +546,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", - "windows-link 0.2.0", + "wasm-bindgen", + "windows-link 0.2.1", ] [[package]] @@ -618,7 +626,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "core-foundation", "core-graphics-types", "foreign-types", @@ -631,7 +639,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "core-foundation", "libc", ] @@ -737,7 +745,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -747,7 +755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -771,7 +779,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -782,7 +790,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -793,9 +801,9 @@ checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" [[package]] name = "deranged" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", "serde_core", @@ -809,7 +817,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -822,7 +830,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -854,7 +862,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -869,10 +877,10 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.9.4", - "block2 0.6.1", + "bitflags 2.10.0", + "block2 0.6.2", "libc", - "objc2 0.6.2", + "objc2 0.6.3", ] [[package]] @@ -883,7 +891,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -892,7 +900,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" dependencies = [ - "libloading", + "libloading 0.8.9", ] [[package]] @@ -915,7 +923,7 @@ checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -975,7 +983,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.7", + "toml 0.9.8", "vswhom", "winreg", ] @@ -1010,14 +1018,14 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] name = "env_filter" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" dependencies = [ "log", "regex", @@ -1047,7 +1055,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -1071,6 +1079,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1107,15 +1127,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" [[package]] name = "flate2" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", "libz-rs-sys", @@ -1146,7 +1166,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -1189,6 +1209,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1196,6 +1231,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1242,7 +1278,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -1263,6 +1299,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1384,9 +1421,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -1416,22 +1453,16 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.7+wasi-0.2.4", + "wasip2", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "gio" version = "0.18.4" @@ -1470,7 +1501,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "futures-channel", "futures-core", "futures-executor", @@ -1498,7 +1529,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -1577,7 +1608,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -1586,7 +1617,16 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", ] [[package]] @@ -1595,6 +1635,15 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1731,7 +1780,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.1", + "windows-core 0.62.2", ] [[package]] @@ -1755,9 +1804,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", @@ -1768,9 +1817,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -1781,11 +1830,10 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -1796,42 +1844,38 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", - "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -1879,9 +1923,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.4" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", "hashbrown 0.16.0", @@ -1904,7 +1948,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "inotify-sys", "libc", ] @@ -1927,17 +1971,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "io-uring" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" -dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "libc", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -1946,9 +1979,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" dependencies = [ "memchr", "serde", @@ -2011,15 +2044,15 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" dependencies = [ "once_cell", "wasm-bindgen", @@ -2053,7 +2086,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "serde", "unicode-segmentation", ] @@ -2086,7 +2119,7 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ "cssparser", "html5ever", - "indexmap 2.11.4", + "indexmap 2.12.0", "selectors", ] @@ -2116,7 +2149,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading", + "libloading 0.7.4", "once_cell", ] @@ -2128,9 +2161,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.176" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libloading" @@ -2142,16 +2175,37 @@ dependencies = [ "winapi", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libredox" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libz-rs-sys" version = "0.5.2" @@ -2169,17 +2223,16 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] @@ -2230,7 +2283,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -2272,14 +2325,14 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", "log", "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2292,10 +2345,10 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "objc2 0.6.2", + "objc2 0.6.3", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "once_cell", "png", "serde", @@ -2309,7 +2362,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "jni-sys", "log", "ndk-sys", @@ -2345,7 +2398,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "cfg-if", "cfg_aliases", "libc", @@ -2364,7 +2417,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "fsevent-sys", "inotify", "kqueue", @@ -2399,9 +2452,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" dependencies = [ "num_enum_derive", "rustversion", @@ -2409,14 +2462,14 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -2446,9 +2499,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561f357ba7f3a2a61563a186a163d0a3a5247e1089524a3981d49adb775078bc" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -2456,77 +2509,104 @@ dependencies = [ [[package]] name = "objc2-app-kit" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.9.4", - "block2 0.6.1", + "bitflags 2.10.0", + "block2 0.6.2", "libc", - "objc2 0.6.2", + "objc2 0.6.3", "objc2-cloud-kit", "objc2-core-data", "objc2-core-foundation", "objc2-core-graphics", "objc2-core-image", - "objc2-foundation 0.3.1", - "objc2-quartz-core 0.3.1", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] name = "objc2-cloud-kit" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.2", - "objc2-foundation 0.3.1", + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-foundation 0.3.2", ] [[package]] name = "objc2-core-data" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.2", - "objc2-foundation 0.3.1", + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-foundation 0.3.2", ] [[package]] name = "objc2-core-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "dispatch2", - "objc2 0.6.2", + "objc2 0.6.3", ] [[package]] name = "objc2-core-graphics" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "dispatch2", - "objc2 0.6.2", + "objc2 0.6.3", "objc2-core-foundation", "objc2-io-surface", ] [[package]] name = "objc2-core-image" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2 0.6.2", - "objc2-foundation 0.3.1", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", ] [[package]] @@ -2550,7 +2630,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -2558,35 +2638,35 @@ dependencies = [ [[package]] name = "objc2-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.9.4", - "block2 0.6.1", + "bitflags 2.10.0", + "block2 0.6.2", "libc", - "objc2 0.6.2", + "objc2 0.6.3", "objc2-core-foundation", ] [[package]] name = "objc2-io-surface" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.2", + "bitflags 2.10.0", + "objc2 0.6.3", "objc2-core-foundation", ] [[package]] name = "objc2-javascript-core" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9052cb1bb50a4c161d934befcf879526fb87ae9a68858f241e693ca46225cf5a" +checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" dependencies = [ - "objc2 0.6.2", + "objc2 0.6.3", "objc2-core-foundation", ] @@ -2596,7 +2676,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2608,7 +2688,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2617,63 +2697,54 @@ dependencies = [ [[package]] name = "objc2-quartz-core" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.2", - "objc2-foundation 0.3.1", + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-foundation 0.3.2", ] [[package]] name = "objc2-security" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1f8e0ef3ab66b08c42644dcb34dba6ec0a574bbd8adbb8bdbdc7a2779731a44" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.2", + "bitflags 2.10.0", + "objc2 0.6.3", "objc2-core-foundation", ] [[package]] name = "objc2-ui-kit" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.2", + "bitflags 2.10.0", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", ] [[package]] name = "objc2-web-kit" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.9.4", - "block2 0.6.1", - "objc2 0.6.2", + "bitflags 2.10.0", + "block2 0.6.2", + "objc2 0.6.3", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "objc2-javascript-core", "objc2-security", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -2729,9 +2800,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -2739,15 +2810,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] @@ -2870,7 +2941,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -2925,7 +2996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.11.4", + "indexmap 2.12.0", "quick-xml 0.38.3", "serde", "time", @@ -2946,9 +3017,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] @@ -2961,9 +3032,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppmd-rust" -version = "1.2.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c834641d8ad1b348c9ee86dec3b9840d805acd5f24daa5f90c788951a52ff59b" +checksum = "d558c559f0450f16f2a27a1f017ef38468c1090c9ce63c8e51366232d53717b4" [[package]] name = "ppv-lite86" @@ -3006,7 +3077,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.23.6", + "toml_edit 0.23.7", ] [[package]] @@ -3041,9 +3112,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] @@ -3098,9 +3169,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -3206,7 +3277,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] [[package]] @@ -3255,11 +3326,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.17" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", ] [[package]] @@ -3290,14 +3361,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] name = "regex" -version = "1.11.3" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -3307,9 +3378,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -3318,9 +3389,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "rend" @@ -3333,9 +3404,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.23" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64 0.22.1", "bytes", @@ -3373,17 +3444,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" dependencies = [ "ashpd", - "block2 0.6.1", + "block2 0.6.2", "dispatch2", "glib-sys", "gobject-sys", "gtk-sys", "js-sys", "log", - "objc2 0.6.2", + "objc2 0.6.3", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "raw-window-handle", "wasm-bindgen", "wasm-bindgen-futures", @@ -3420,11 +3491,26 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.10.0", + "chrono", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust_decimal" -version = "1.38.0" +version = "1.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8975fc98059f365204d635119cf9c5a60ae67b841ed49b5422a9a7e56cdfac0" +checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282" dependencies = [ "arrayvec", "borsh", @@ -3436,12 +3522,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - [[package]] name = "rustc_version" version = "0.4.1" @@ -3457,11 +3537,11 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -3514,9 +3594,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" dependencies = [ "dyn-clone", "ref-cast", @@ -3533,7 +3613,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -3633,7 +3713,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -3644,7 +3724,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -3668,7 +3748,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -3682,9 +3762,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" dependencies = [ "serde_core", ] @@ -3703,19 +3783,18 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.14.1" +version = "3.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" +checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.11.4", + "indexmap 2.12.0", "schemars 0.9.0", - "schemars 1.0.4", - "serde", - "serde_derive", + "schemars 1.1.0", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -3723,14 +3802,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.14.1" +version = "3.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" +checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -3752,7 +3831,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -3840,12 +3919,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3898,9 +3977,9 @@ dependencies = [ [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" @@ -3969,9 +4048,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.106" +version = "2.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" dependencies = [ "proc-macro2", "quote", @@ -3995,7 +4074,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -4017,8 +4096,8 @@ version = "0.34.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ - "bitflags 2.9.4", - "block2 0.6.1", + "bitflags 2.10.0", + "block2 0.6.2", "core-foundation", "core-graphics", "crossbeam-channel", @@ -4035,9 +4114,9 @@ dependencies = [ "ndk", "ndk-context", "ndk-sys", - "objc2 0.6.2", + "objc2 0.6.3", "objc2-app-kit", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "once_cell", "parking_lot", "raw-window-handle", @@ -4059,7 +4138,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -4076,9 +4155,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.9.0" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f07c6590706b2fc0ab287b041cf5ce9c435b3850bdae5571e19d9d27584e89d" +checksum = "8bceb52453e507c505b330afe3398510e87f428ea42b6e76ecb6bd63b15965b5" dependencies = [ "anyhow", "bytes", @@ -4086,7 +4165,7 @@ dependencies = [ "dirs", "dunce", "embed_plist", - "getrandom 0.3.3", + "getrandom 0.3.4", "glob", "gtk", "heck 0.5.0", @@ -4096,9 +4175,9 @@ dependencies = [ "log", "mime", "muda", - "objc2 0.6.2", + "objc2 0.6.3", "objc2-app-kit", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "objc2-ui-kit", "objc2-web-kit", "percent-encoding", @@ -4119,7 +4198,6 @@ dependencies = [ "tokio", "tray-icon", "url", - "urlpattern", "webkit2gtk", "webview2-com", "window-vibrancy", @@ -4128,9 +4206,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.0" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f71be1f494b683ac439e6d61c16ab5c472c6f9c6ee78995b29556d9067c021a1" +checksum = "a924b6c50fe83193f0f8b14072afa7c25b7a72752a2a73d9549b463f5fe91a38" dependencies = [ "anyhow", "cargo_toml", @@ -4144,7 +4222,7 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.7", + "toml 0.9.8", "walkdir", ] @@ -4166,7 +4244,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.106", + "syn 2.0.109", "tauri-utils", "thiserror 2.0.17", "time", @@ -4184,16 +4262,16 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", "tauri-codegen", "tauri-utils", ] [[package]] name = "tauri-plugin" -version = "2.4.0" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9946a3cede302eac0c6eb6c6070ac47b1768e326092d32efbb91f21ed58d978f" +checksum = "076c78a474a7247c90cad0b6e87e593c4c620ed4efdb79cbe0214f0021f6c39d" dependencies = [ "anyhow", "glob", @@ -4202,15 +4280,15 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.7", + "toml 0.9.8", "walkdir", ] [[package]] name = "tauri-plugin-dialog" -version = "2.4.0" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beee42a4002bc695550599b011728d9dfabf82f767f134754ed6655e434824e" +checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19" dependencies = [ "log", "raw-window-handle", @@ -4226,9 +4304,9 @@ dependencies = [ [[package]] name = "tauri-plugin-fs" -version = "2.4.2" +version = "2.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "315784ec4be45e90a987687bae7235e6be3d6e9e350d2b75c16b8a4bf22c1db7" +checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9" dependencies = [ "anyhow", "dunce", @@ -4242,22 +4320,22 @@ dependencies = [ "tauri-plugin", "tauri-utils", "thiserror 2.0.17", - "toml 0.9.7", + "toml 0.9.8", "url", ] [[package]] name = "tauri-plugin-log" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c1438bc7662acd16d508c919b3c087efd63669a4c75625dff829b1c75975ec" +checksum = "d5709c792b8630290b5d9811a1f8fe983dd925fc87c7fc7f4923616458cd00b6" dependencies = [ "android_logger", "byte-unit", "fern", "log", - "objc2 0.6.2", - "objc2-foundation 0.3.1", + "objc2 0.6.3", + "objc2-foundation 0.3.2", "serde", "serde_json", "serde_repr", @@ -4270,16 +4348,16 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3367f0b47df90e9195cd9f04a56b0055a2cba45aa11923c6c253d748778176fc" +checksum = "9368f09358496f2229313fccb37682ad116b7f46fa76981efe116994a0628926" dependencies = [ "cookie", "dpi", "gtk", "http", "jni", - "objc2 0.6.2", + "objc2 0.6.3", "objc2-ui-kit", "objc2-web-kit", "raw-window-handle", @@ -4295,17 +4373,17 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d91d29ca680c545364cf75ba2f2e3c7ea2ab6376bfa3be26b56fa2463a5b5e" +checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93" dependencies = [ "gtk", "http", "jni", "log", - "objc2 0.6.2", + "objc2 0.6.3", "objc2-app-kit", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "once_cell", "percent-encoding", "raw-window-handle", @@ -4351,7 +4429,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.17", - "toml 0.9.7", + "toml 0.9.8", "url", "urlpattern", "uuid", @@ -4365,7 +4443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd21509dd1fa9bd355dc29894a6ff10635880732396aa38c0066c1e6c1ab8074" dependencies = [ "embed-resource", - "toml 0.9.7", + "toml 0.9.8", ] [[package]] @@ -4375,10 +4453,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -4418,7 +4496,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -4429,7 +4507,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -4467,9 +4545,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -4492,28 +4570,38 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.47.1" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", "socket2", + "tokio-macros", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", ] [[package]] name = "tokio-util" -version = "0.7.16" +version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", @@ -4536,14 +4624,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.7" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.12.0", "serde_core", - "serde_spanned 1.0.2", - "toml_datetime 0.7.2", + "serde_spanned 1.0.3", + "toml_datetime 0.7.3", "toml_parser", "toml_writer", "winnow 0.7.13", @@ -4560,9 +4648,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" dependencies = [ "serde_core", ] @@ -4573,7 +4661,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.12.0", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -4584,7 +4672,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.12.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -4593,30 +4681,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.6" +version = "0.23.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" dependencies = [ - "indexmap 2.11.4", - "toml_datetime 0.7.2", + "indexmap 2.12.0", + "toml_datetime 0.7.3", "toml_parser", "winnow 0.7.13", ] [[package]] name = "toml_parser" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ "winnow 0.7.13", ] [[package]] name = "toml_writer" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" [[package]] name = "tower" @@ -4639,7 +4727,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "bytes", "futures-util", "http", @@ -4682,7 +4770,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -4696,24 +4784,24 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" +checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b" dependencies = [ "crossbeam-channel", "dirs", "libappindicator", "muda", - "objc2 0.6.2", + "objc2 0.6.3", "objc2-app-kit", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "once_cell", "png", "serde", "thiserror 2.0.17", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -4730,9 +4818,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "uds_windows" @@ -4788,9 +4876,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-segmentation" @@ -4846,7 +4934,7 @@ version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "js-sys", "serde", "wasm-bindgen", @@ -4858,11 +4946,17 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" [[package]] name = "version_check" @@ -4921,15 +5015,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -4941,9 +5026,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" dependencies = [ "cfg-if", "once_cell", @@ -4952,25 +5037,11 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.106", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.54" +version = "0.4.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" dependencies = [ "cfg-if", "js-sys", @@ -4981,9 +5052,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4991,22 +5062,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.106", - "wasm-bindgen-backend", + "syn 2.0.109", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" dependencies = [ "unicode-ident", ] @@ -5044,7 +5115,7 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "rustix", "wayland-backend", "wayland-scanner", @@ -5056,7 +5127,7 @@ version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -5086,9 +5157,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" dependencies = [ "js-sys", "wasm-bindgen", @@ -5160,7 +5231,7 @@ checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -5196,7 +5267,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -5211,10 +5282,10 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "objc2 0.6.2", + "objc2 0.6.3", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "raw-window-handle", "windows-sys 0.59.0", "windows-version", @@ -5257,15 +5328,15 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.62.1" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.0", - "windows-result 0.4.0", - "windows-strings 0.5.0", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -5281,24 +5352,24 @@ dependencies = [ [[package]] name = "windows-implement" -version = "0.60.1" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] name = "windows-interface" -version = "0.59.2" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -5309,9 +5380,9 @@ checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-link" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-numerics" @@ -5334,11 +5405,11 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -5352,11 +5423,11 @@ dependencies = [ [[package]] name = "windows-strings" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -5383,16 +5454,16 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.4", + "windows-targets 0.53.5", ] [[package]] name = "windows-sys" -version = "0.61.1" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -5428,19 +5499,19 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.4" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.0", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -5454,11 +5525,11 @@ dependencies = [ [[package]] name = "windows-version" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "700dad7c058606087f6fdc1f88da5841e06da40334413c6cd4367b25ef26d24e" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -5475,9 +5546,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -5493,9 +5564,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -5511,9 +5582,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -5523,9 +5594,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -5541,9 +5612,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -5559,9 +5630,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -5577,9 +5648,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -5595,9 +5666,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" @@ -5635,18 +5706,18 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wry" -version = "0.53.4" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d78ec082b80fa088569a970d043bb3050abaabf4454101d44514ee8d9a8c9f6" +checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" dependencies = [ "base64 0.22.1", - "block2 0.6.1", + "block2 0.6.2", "cookie", "crossbeam-channel", "dirs", @@ -5661,10 +5732,10 @@ dependencies = [ "kuchikiki", "libc", "ndk", - "objc2 0.6.2", + "objc2 0.6.3", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "objc2-ui-kit", "objc2-web-kit", "once_cell", @@ -5716,17 +5787,16 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.27" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -5734,21 +5804,21 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", "synstructure", ] [[package]] name = "zbus" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" dependencies = [ "async-broadcast", "async-recursion", @@ -5765,7 +5835,8 @@ dependencies = [ "tokio", "tracing", "uds_windows", - "windows-sys 0.60.2", + "uuid", + "windows-sys 0.61.2", "winnow 0.7.13", "zbus_macros", "zbus_names", @@ -5774,14 +5845,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", "zbus_names", "zvariant", "zvariant_utils", @@ -5816,7 +5887,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -5836,7 +5907,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", "synstructure", ] @@ -5857,14 +5928,14 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -5873,9 +5944,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -5884,13 +5955,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", ] [[package]] @@ -5906,9 +5977,9 @@ dependencies = [ "crc32fast", "deflate64", "flate2", - "getrandom 0.3.3", + "getrandom 0.3.4", "hmac", - "indexmap 2.11.4", + "indexmap 2.12.0", "lzma-rust2", "memchr", "pbkdf2", @@ -5928,9 +5999,9 @@ checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" [[package]] name = "zopfli" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" dependencies = [ "bumpalo", "crc32fast", @@ -5968,9 +6039,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.7.0" +version = "5.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "999dd3be73c52b1fccd109a4a81e4fcd20fab1d3599c8121b38d04e1419498db" +checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" dependencies = [ "endi", "enumflags2", @@ -5983,14 +6054,14 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.7.0" +version = "5.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6643fd0b26a46d226bd90d3f07c1b5321fe9bb7f04673cb37ac6d6883885b68e" +checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.109", "zvariant_utils", ] @@ -6003,6 +6074,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.106", + "syn 2.0.109", "winnow 0.7.13", ] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4ec4b5b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "2" +members = ["src-tauri", "src-tauri/calibre_db"] diff --git a/devenv.nix b/devenv.nix index add109a..9353589 100644 --- a/devenv.nix +++ b/devenv.nix @@ -39,6 +39,8 @@ # D-Bus / desktop integration dbus xdg-utils + + sqlite ]; languages.rust = { @@ -68,7 +70,7 @@ scripts.dev-app.exec = "cargo tauri dev"; scripts.create-test-cbz.exec = "cd src-tauri && cargo run --bin create_test_cbz"; scripts.create-test-missing-comicinfo.exec = "cd src-tauri && cargo run --bin create_test_missing_comicinfo"; - scripts.test-app.exec = "jest && cargo test --manifest-path ./src-tauri/Cargo.toml"; + scripts.test-app.exec = "jest && cargo test --manifest-path ./src-tauri/Cargo.toml && cargo test --manifest-path ./src-tauri/calibre_db/Cargo.toml"; enterShell = '' export XDG_DATA_DIRS=${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}:$XDG_DATA_DIRS; @@ -79,9 +81,10 @@ eslint src pnpm test pnpm run build - cargo test --manifest-path ./src-tauri/Cargo.toml - cargo check --manifest-path ./src-tauri/Cargo.toml - cargo build --manifest-path ./src-tauri/Cargo.toml + cargo test + cargo check + cargo clippy + cargo build nix flake check nix eval ''; diff --git a/flake.lock b/flake.lock index d134e37..8cd8389 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1759281824, - "narHash": "sha256-FIBE1qXv9TKvSNwst6FumyHwCRH3BlWDpfsnqRDCll0=", + "lastModified": 1762233356, + "narHash": "sha256-cGS3lLTYusbEP/IJIWGgnkzIl+FA5xDvtiHyjalGr4k=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "5b5be50345d4113d04ba58c444348849f5585b4a", + "rev": "ca534a76c4afb2bdc07b681dbc11b453bab21af8", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index f2a2cc9..2649a42 100644 --- a/flake.nix +++ b/flake.nix @@ -24,12 +24,12 @@ src = ./.; - cargoHash = "sha256-m7RauKqcxHWonfhTiEEfA8J0rdZ7TO8IRmcHPWgdMRA="; + cargoHash = "sha256-c5ck7uzDC1d5EMMSr9caYZFoJ/GjQ4aIngoTjaFkbhw="; pnpmDeps = pkgs.pnpm.fetchDeps { inherit (finalAttrs) pname version src; fetcherVersion = 2; - hash = "sha256-lEfzQqomcIJex4XbTCzjUj/yQaMKdv8SvkdpUXT5fpI="; + hash = "sha256-3vi81v4JXNuSXfrAb1DeS2HwoAbLFcp79wEn9NRpcFM="; }; nativeBuildInputs = [ @@ -48,9 +48,6 @@ pkgs.webkitgtk_4_1 ]; - cargoRoot = "src-tauri"; - buildAndTestSubdir = cargoRoot; - # installPhase = '' # mkdir -p $out/bin # diff --git a/package.json b/package.json index 248bcfe..04db423 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.38.0", + "@github/copilot": "^0.0.351", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/react-hooks": "^8.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index caea149..df375e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@eslint/js': specifier: ^9.38.0 version: 9.38.0 + '@github/copilot': + specifier: ^0.0.351 + version: 0.0.351 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -424,6 +427,11 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@github/copilot@0.0.351': + resolution: {integrity: sha512-a3FgxgOvTi3uDVpcD0nxlTwKAGvILeSdf4NKYOAgSQ6HGANJhDGsBtk6O9nUnUAoskkGDDn7vrENM9svOs3sUw==} + engines: {node: '>=22'} + hasBin: true + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -3615,6 +3623,8 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@github/copilot@0.0.351': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index daa3685..cc6e587 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -5,7 +5,7 @@ description = "Kikou: Your All-in-One Hub for Organizing, Tagging, and Managing authors = ["Kevin Hellemun"] license = "" repository = "" -edition = "2024" +edition = "2021" rust-version = "1.86.0" default-run = "Kikou" @@ -33,3 +33,13 @@ once_cell = "1" tempfile = "3.23.0" base64 = "0.22" rayon = "1.10" +tokio = { version = "1.35", features = ["full"] } +async-trait = "0.1" +calibre_db = { path = "./calibre_db" } + +[dev-dependencies] +rusqlite = { version = "0.31", features = ["bundled"] } + +[[test]] +name = "app_tests" +path = "tests/lib_tests.rs" diff --git a/src-tauri/calibre_db/.gitignore b/src-tauri/calibre_db/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/src-tauri/calibre_db/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/src-tauri/calibre_db/Cargo.toml b/src-tauri/calibre_db/Cargo.toml new file mode 100644 index 0000000..5c35164 --- /dev/null +++ b/src-tauri/calibre_db/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "calibre_db" +version = "0.1.0" +edition = "2021" +rust-version = "1.86.0" +description = "Rust library for interacting with Calibre SQLite databases" +license = "" +repository = "" + +[dependencies] +rusqlite = { version = "0.31", features = ["bundled", "chrono"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "1.0" +tokio = { version = "1.35", features = ["full"], optional = true } +futures = { version = "0.3", optional = true } + +[dev-dependencies] +tokio = { version = "1.35", features = ["full"] } +tempfile = "3.8" + +[features] +default = [] +async = ["tokio", "futures"] + +[[test]] +name = "integration_tests" +path = "tests/integration_tests.rs" \ No newline at end of file diff --git a/src-tauri/calibre_db/README.md b/src-tauri/calibre_db/README.md new file mode 100644 index 0000000..a9c79c2 --- /dev/null +++ b/src-tauri/calibre_db/README.md @@ -0,0 +1,238 @@ +# calibre_db + +A Rust library for reading book metadata from Calibre SQLite databases. + +## Overview + +`calibre_db` provides safe, type-safe access to book metadata stored in Calibre's SQLite database format. It abstracts away the complexity of the database schema and query logic, offering a clean API for retrieving books, authors, series, tags, and other metadata. + +This crate is designed to be published on crates.io as a standalone library, making it easy to integrate Calibre database reading into any Rust application. + +## Features + +- **Type-safe models**: Strongly-typed structures for books, authors, series, tags, and identifiers +- **Comprehensive metadata**: Retrieve all book details including cover information, formats, ratings, and comments +- **Many-to-many relations**: Proper handling of complex relationships (authors per book, tags per book, etc.) +- **Error handling**: Comprehensive error types with conversions from common error sources +- **Thread-safe**: Uses `rusqlite` with safe connection handling +- **Well-tested**: Comprehensive unit and integration tests + +## Installation + +Add to your `Cargo.toml`: + +```toml +[dependencies] +calibre_db = "0.1" +``` + +## Quick Start + +```rust +use calibre_db::CalibreDatabase; + +fn main() -> Result<(), Box> { + let db = CalibreDatabase::open("/path/to/library/metadata.db")?; + + // Get a specific book + let book = db.get_book(1)?; + println!("Title: {}", book.title); + println!("Authors: {:?}", book.authors); + + // Get all books + let books = db.all_books()?; + println!("Total books: {}", books.len()); + + Ok(()) +} +``` + +## Database Schema + +The crate works with Calibre's standard SQLite schema, which includes: + +- **books**: Core book information (title, dates, ISBN, etc.) +- **authors**: Author metadata with sort names +- **publishers**: Publisher information +- **tags**: Tag/category information +- **series**: Series metadata +- **data**: Format information (EPUB, PDF, MOBI, etc.) +- **comments**: Book descriptions/comments +- **ratings**: Star ratings +- **identifiers**: Book identifiers (ISBN, DOI, etc.) +- **languages**: Language information + +All many-to-many relationships are properly handled through link tables. + +## API Overview + +### Main Entry Point + +```rust +pub struct CalibreDatabase { + // ... +} + +impl CalibreDatabase { + pub fn open>(path: P) -> Result; + pub fn get_book(&self, book_id: u32) -> Result; + pub fn all_books(&self) -> Result>; +} +``` + +### Core Models + +- `Book`: Complete book information with all relations +- `Author`: Author data with sort name +- `Series`: Series information +- `Tag`: Tag/category information +- `Identifier`: Book identifier (ISBN, DOI, etc.) +- `BookMetadata`: Flattened metadata summary + +## Examples + +### Retrieve a Single Book + +```rust +use calibre_db::CalibreDatabase; + +let db = CalibreDatabase::open("metadata.db")?; +let book = db.get_book(42)?; + +println!("Title: {}", book.title); +println!("Authors: {}", book.authors.iter() + .map(|a| &a.name) + .collect::>() + .join(", ")); +println!("Series: {}", book.series.as_ref().map(|s| &s.name).unwrap_or(&"None".to_string())); +println!("Tags: {}", book.tags.iter() + .map(|t| &t.name) + .collect::>() + .join(", ")); +``` + +### Retrieve All Books + +```rust +let db = CalibreDatabase::open("metadata.db")?; +let books = db.all_books()?; + +for book in books { + println!("{}: {} by {}", + book.id, + book.title, + book.author_sort); +} +``` + +### Access Book Details + +```rust +let book = db.get_book(1)?; + +// Basic information +println!("Title: {}", book.title); +println!("Path: {}", book.path); +println!("Has cover: {}", book.has_cover); + +// Dates +println!("Published: {}", book.pubdate); +println!("Added: {}", book.timestamp); + +// Relations +println!("Authors: {} ({})", + book.authors.len(), + book.author_sort); +println!("Publishers: {}", book.publishers.join(", ")); +println!("Tags: {}", book.tags.iter().map(|t| &t.name).collect::>().join(", ")); +println!("Languages: {}", book.languages.join(", ")); +println!("Formats: {}", book.formats.join(", ")); + +// Optional fields +if let Some(series) = &book.series { + println!("Series: {} ({})", series.name, book.series_index); +} +if let Some(rating) = book.rating { + println!("Rating: {}/5", rating); +} +if let Some(comments) = &book.comments { + println!("Comments: {}", comments); +} +``` + +## Error Handling + +The crate provides comprehensive error types: + +```rust +use calibre_db::{CalibreDatabase, CalibreDbError}; + +match db.get_book(9999) { + Ok(book) => println!("Found: {}", book.title), + Err(CalibreDbError::DatabaseError(msg)) => eprintln!("DB Error: {}", msg), + Err(CalibreDbError::NotFound(msg)) => eprintln!("Not found: {}", msg), + Err(e) => eprintln!("Error: {}", e), +} +``` + +## Performance Notes + +- Books are fetched with all related metadata in a single operation +- The database uses indexes for efficient querying +- Results are ordered by book sort name for consistency +- Consider caching if you need to query the same book multiple times + +## Thread Safety + +`CalibreDatabase` is thread-safe and can be safely shared across threads using `Arc`: + +```rust +use std::sync::Arc; +use calibre_db::CalibreDatabase; + +let db = Arc::new(CalibreDatabase::open("metadata.db")?); +let db_clone = Arc::clone(&db); + +std::thread::spawn(move || { + let book = db_clone.get_book(1); + // ... +}); +``` + +## Testing + +Run the test suite: + +```bash +cargo test +``` + +Run integration tests specifically: + +```bash +cargo test --test integration_tests +``` + +## Limitations + +- Read-only: This crate currently supports reading only; modifications to the database are not supported +- Calibre format required: The database must be in Calibre's SQLite format +- No async support: Operations are synchronous (async support may be added as a feature in the future) + +## Contributing + +Contributions are welcome! Please ensure: + +- All tests pass: `cargo test` +- Code is formatted: `cargo fmt` +- No clippy warnings: `cargo clippy` +- New features include tests + +## License + +[Your License Here] + +## Related Projects + +- [Calibre](https://calibre-ebook.com/): The main e-book management software +- [Kikou](https://github.com/yourusername/kikou): The Tauri application using this crate diff --git a/src-tauri/calibre_db/src/error.rs b/src-tauri/calibre_db/src/error.rs new file mode 100644 index 0000000..4574b75 --- /dev/null +++ b/src-tauri/calibre_db/src/error.rs @@ -0,0 +1,61 @@ +use std::fmt; + +#[derive(Debug)] +pub enum CalibreDbError { + DatabaseError(rusqlite::Error), + NotFound(String), + InvalidData(String), + IoError(String), + SerializationError(String), +} + +impl fmt::Display for CalibreDbError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CalibreDbError::DatabaseError(err) => write!(f, "Database error: {}", err), + CalibreDbError::NotFound(msg) => write!(f, "Not found: {}", msg), + CalibreDbError::InvalidData(msg) => write!(f, "Invalid data: {}", msg), + CalibreDbError::IoError(msg) => write!(f, "IO error: {}", msg), + CalibreDbError::SerializationError(msg) => write!(f, "Serialization error: {}", msg), + } + } +} + +impl std::error::Error for CalibreDbError {} + +impl From for CalibreDbError { + fn from(err: rusqlite::Error) -> Self { + CalibreDbError::DatabaseError(err) + } +} + +impl From for CalibreDbError { + fn from(err: std::io::Error) -> Self { + CalibreDbError::IoError(err.to_string()) + } +} + +impl From for CalibreDbError { + fn from(err: serde_json::Error) -> Self { + CalibreDbError::SerializationError(err.to_string()) + } +} + +pub type Result = std::result::Result; + +/// Extension trait to convert `QueryReturnedNoRows` errors into `Ok(None)`. +/// This is useful for queries that may or may not return a row. +pub trait OptionalResult { + /// Converts a database query result to an `Option`, treating `QueryReturnedNoRows` as `None`. + fn optional(self) -> Result>; +} + +impl OptionalResult for Result { + fn optional(self) -> Result> { + match self { + Ok(value) => Ok(Some(value)), + Err(CalibreDbError::DatabaseError(rusqlite::Error::QueryReturnedNoRows)) => Ok(None), + Err(e) => Err(e), + } + } +} diff --git a/src-tauri/calibre_db/src/lib.rs b/src-tauri/calibre_db/src/lib.rs new file mode 100644 index 0000000..0bbaf9a --- /dev/null +++ b/src-tauri/calibre_db/src/lib.rs @@ -0,0 +1,320 @@ +mod error; +mod models; +mod schema; + +pub use error::{CalibreDbError, OptionalResult, Result}; +pub use models::{Author, Book, BookMetadata, Identifier, Series, Tag}; +pub use schema::DatabaseConnection; + +use chrono::{DateTime, Utc}; +use rusqlite::params; +use std::path::Path; + +/// Struct representing a row from the books table. +/// Used to extract data from SQL queries before building Book objects. +struct BookRow { + id: u32, + title: String, + sort: String, + timestamp_str: String, + pubdate_str: String, + series_index: f32, + author_sort: String, + isbn: String, + lccn: String, + path: String, + has_cover: i32, +} + +impl BookRow { + // Field indices for books table SELECT query + const IDX_ID: usize = 0; + const IDX_TITLE: usize = 1; + const IDX_SORT: usize = 2; + const IDX_TIMESTAMP: usize = 3; + const IDX_PUBDATE: usize = 4; + const IDX_SERIES_INDEX: usize = 5; + const IDX_AUTHOR_SORT: usize = 6; + const IDX_ISBN: usize = 7; + const IDX_LCCN: usize = 8; + const IDX_PATH: usize = 9; + const IDX_HAS_COVER: usize = 10; + + /// Extracts a BookRow from a rusqlite Row. + fn from_row(row: &rusqlite::Row) -> rusqlite::Result { + Ok(BookRow { + id: row.get(Self::IDX_ID)?, + title: row.get(Self::IDX_TITLE)?, + sort: row.get(Self::IDX_SORT)?, + timestamp_str: row.get(Self::IDX_TIMESTAMP)?, + pubdate_str: row.get(Self::IDX_PUBDATE)?, + series_index: row.get(Self::IDX_SERIES_INDEX)?, + author_sort: row.get(Self::IDX_AUTHOR_SORT)?, + isbn: row.get(Self::IDX_ISBN)?, + lccn: row.get(Self::IDX_LCCN)?, + path: row.get(Self::IDX_PATH)?, + has_cover: row.get(Self::IDX_HAS_COVER)?, + }) + } +} + +/// Trait defining all read-only database operations for Calibre libraries. +/// +/// This trait provides a compile-time guarantee that implementing types +/// only perform SELECT queries. +/// +/// # Safety +/// Types implementing this trait must ensure that: +/// - All database operations are SELECT statements only +/// - No INSERT, UPDATE, DELETE, or other write operations are performed +/// - The underlying connection is thread-safe (SQLite with proper locking) +pub trait ReadOnlyDatabase: Send + Sync { + fn get_book(&self, book_id: u32) -> Result; + fn all_books(&self) -> Result>; + fn fetch_book_authors(&self, book_id: u32) -> Result>; + fn fetch_book_publishers(&self, book_id: u32) -> Result>; + fn fetch_book_tags(&self, book_id: u32) -> Result>; + fn fetch_book_series(&self, book_id: u32) -> Result>; + fn fetch_book_comments(&self, book_id: u32) -> Result>; + fn fetch_book_rating(&self, book_id: u32) -> Result>; + fn fetch_book_formats(&self, book_id: u32) -> Result>; + fn fetch_book_identifiers(&self, book_id: u32) -> Result>; + fn fetch_book_languages(&self, book_id: u32) -> Result>; +} + +pub struct CalibreDatabase { + conn: DatabaseConnection, +} + +impl ReadOnlyDatabase for CalibreDatabase { + fn get_book(&self, book_id: u32) -> Result { + let book_row = self.conn.query_row( + "SELECT id, title, sort, timestamp, pubdate, series_index, author_sort, isbn, lccn, path, has_cover + FROM books WHERE id = ?1", + params![book_id], + BookRow::from_row, + )?; + + Book::builder(book_row.id, book_row.title, book_row.path, self) + .sort(book_row.sort) + .timestamp(parse_timestamp(&book_row.timestamp_str)) + .pubdate(parse_timestamp(&book_row.pubdate_str)) + .series_index(book_row.series_index) + .author_sort(book_row.author_sort) + .isbn(book_row.isbn) + .lccn(book_row.lccn) + .has_cover(book_row.has_cover != 0) + .fetch_all() + .build() + } + + fn all_books(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, title, sort, timestamp, pubdate, series_index, author_sort, isbn, lccn, path, has_cover + FROM books ORDER BY sort ASC")?; + + let books = stmt + .query_map([], BookRow::from_row) + .map_err(CalibreDbError::from)? + .collect::, _>>() + .map_err(CalibreDbError::from)?; + + let enriched_books = books + .into_iter() + .map(|book_row| { + Book::builder(book_row.id, book_row.title, book_row.path, self) + .sort(book_row.sort) + .timestamp(parse_timestamp(&book_row.timestamp_str)) + .pubdate(parse_timestamp(&book_row.pubdate_str)) + .series_index(book_row.series_index) + .author_sort(book_row.author_sort) + .isbn(book_row.isbn) + .lccn(book_row.lccn) + .has_cover(book_row.has_cover != 0) + .fetch_all() + .build() + }) + .collect::>>()?; + + Ok(enriched_books) + } + + fn fetch_book_authors(&self, book_id: u32) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT a.id, a.name, a.sort + FROM authors a + JOIN books_authors_link bal ON a.id = bal.author + WHERE bal.book = ?1 + ORDER BY bal.id ASC", + )?; + + let authors = stmt + .query_map(params![book_id], |row| { + Ok(Author::new(row.get(0)?, row.get(1)?, row.get(2)?)) + }) + .map_err(CalibreDbError::from)? + .collect::, _>>() + .map_err(CalibreDbError::from)?; + + Ok(authors) + } + + fn fetch_book_publishers(&self, book_id: u32) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT p.name + FROM publishers p + JOIN books_publishers_link bpl ON p.id = bpl.publisher + WHERE bpl.book = ?1", + )?; + + let publishers = stmt + .query_map(params![book_id], |row| row.get(0)) + .map_err(CalibreDbError::from)? + .collect::, _>>() + .map_err(CalibreDbError::from)?; + + Ok(publishers) + } + + fn fetch_book_tags(&self, book_id: u32) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT t.id, t.name + FROM tags t + JOIN books_tags_link btl ON t.id = btl.tag + WHERE btl.book = ?1", + )?; + + let tags = stmt + .query_map(params![book_id], |row| { + Ok(Tag::new(row.get(0)?, row.get(1)?)) + }) + .map_err(CalibreDbError::from)? + .collect::, _>>() + .map_err(CalibreDbError::from)?; + + Ok(tags) + } + + fn fetch_book_series(&self, book_id: u32) -> Result> { + self.conn + .query_row( + "SELECT s.id, s.name + FROM series s + JOIN books_series_link bsl ON s.id = bsl.series + WHERE bsl.book = ?1", + params![book_id], + |row| Ok(Series::new(row.get(0)?, row.get(1)?)), + ) + .optional() + } + + fn fetch_book_comments(&self, book_id: u32) -> Result> { + self.conn + .query_row( + "SELECT text FROM comments WHERE book = ?1", + params![book_id], + |row| row.get(0), + ) + .optional() + } + + fn fetch_book_rating(&self, book_id: u32) -> Result> { + self.conn + .query_row( + "SELECT r.rating + FROM ratings r + JOIN books_ratings_link brl ON r.id = brl.rating + WHERE brl.book = ?1", + params![book_id], + |row| { + let rating: u32 = row.get(0)?; + Ok((rating / 2) as u8) + }, + ) + .optional() + } + + fn fetch_book_formats(&self, book_id: u32) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT format FROM data WHERE book = ?1 ORDER BY id ASC")?; + + let formats = stmt + .query_map(params![book_id], |row| row.get(0)) + .map_err(CalibreDbError::from)? + .collect::, _>>() + .map_err(CalibreDbError::from)?; + + Ok(formats) + } + + fn fetch_book_identifiers(&self, book_id: u32) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT book, type, val FROM identifiers WHERE book = ?1")?; + + let identifiers = stmt + .query_map(params![book_id], |row| { + Ok(Identifier::new(row.get(0)?, row.get(1)?, row.get(2)?)) + }) + .map_err(CalibreDbError::from)? + .collect::, _>>() + .map_err(CalibreDbError::from)?; + + Ok(identifiers) + } + + fn fetch_book_languages(&self, book_id: u32) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT l.lang_code + FROM languages l + JOIN books_languages_link bll ON l.id = bll.lang_code + WHERE bll.book = ?1 + ORDER BY bll.item_order ASC", + )?; + + let languages = stmt + .query_map(params![book_id], |row| row.get(0)) + .map_err(CalibreDbError::from)? + .collect::, _>>() + .map_err(CalibreDbError::from)?; + + Ok(languages) + } +} + +// SAFETY: CalibreDatabase implements ReadOnlyDatabase trait, which by design enforces +// read-only operations through its API. All methods perform only SELECT queries. +// rusqlite::Connection is thread-safe and uses SQLite's built-in locking mechanisms. +// If write operations are needed, introduce a separate ReadWriteDatabase trait. +unsafe impl Send for CalibreDatabase {} +unsafe impl Sync for CalibreDatabase {} + +fn parse_timestamp(timestamp_str: &str) -> DateTime { + DateTime::parse_from_rfc3339(timestamp_str) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or(DateTime::::UNIX_EPOCH) +} + +impl CalibreDatabase { + pub fn open>(path: P) -> Result { + let conn = DatabaseConnection::new(&path)?; + Ok(CalibreDatabase { conn }) + } + + pub fn connection(&self) -> &DatabaseConnection { + &self.conn + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_creation() { + let err = CalibreDbError::DatabaseError(rusqlite::Error::QueryReturnedNoRows); + assert!(matches!(err, CalibreDbError::DatabaseError(_))); + } +} diff --git a/src-tauri/calibre_db/src/models.rs b/src-tauri/calibre_db/src/models.rs new file mode 100644 index 0000000..cadbcb0 --- /dev/null +++ b/src-tauri/calibre_db/src/models.rs @@ -0,0 +1,574 @@ +use crate::ReadOnlyDatabase; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Book { + pub id: u32, + pub title: String, + pub sort: String, + pub timestamp: DateTime, + pub pubdate: DateTime, + pub series_index: f32, + pub author_sort: String, + pub isbn: String, + pub lccn: String, + pub path: String, + pub has_cover: bool, + pub authors: Vec, + pub publishers: Vec, + pub tags: Vec, + pub series: Option, + pub comments: Option, + pub rating: Option, + pub formats: Vec, + pub identifiers: Vec, + pub languages: Vec, +} + +impl Book { + /// Creates a new book builder with the given ID, title, path, and database reference. + /// + /// The database is required to enable automatic relation fetching during `build()`. + /// The builder's lifetime is tied to the database reference, ensuring type-safe access. + /// + /// # Examples + /// + /// ```ignore + /// use calibre_db::Book; + /// let book = Book::builder(1, "Title".into(), "/path".into(), &db) + /// .sort("book, title".into()) + /// .fetch_all() + /// .build()?; + /// assert_eq!(book.id, 1); + /// assert_eq!(book.title, "Title"); + /// ``` + pub fn builder( + id: u32, + title: String, + path: String, + db: &dyn ReadOnlyDatabase, + ) -> BookBuilder<'_> { + BookBuilder { + id, + title, + path, + sort: String::new(), + timestamp: Utc::now(), + pubdate: Utc::now(), + series_index: 0.0, + author_sort: String::new(), + isbn: String::new(), + lccn: String::new(), + has_cover: false, + authors: Vec::new(), + publishers: Vec::new(), + tags: Vec::new(), + series: None, + comments: None, + rating: None, + formats: Vec::new(), + identifiers: Vec::new(), + languages: Vec::new(), + fetch_authors: false, + fetch_publishers: false, + fetch_tags: false, + fetch_series: false, + fetch_comments: false, + fetch_rating: false, + fetch_formats: false, + fetch_identifiers: false, + fetch_languages: false, + db, + } + } +} + +pub struct BookBuilder<'a> { + id: u32, + title: String, + path: String, + sort: String, + timestamp: DateTime, + pubdate: DateTime, + series_index: f32, + author_sort: String, + isbn: String, + lccn: String, + has_cover: bool, + authors: Vec, + publishers: Vec, + tags: Vec, + series: Option, + comments: Option, + rating: Option, + formats: Vec, + identifiers: Vec, + languages: Vec, + fetch_authors: bool, + fetch_publishers: bool, + fetch_tags: bool, + fetch_series: bool, + fetch_comments: bool, + fetch_rating: bool, + fetch_formats: bool, + fetch_identifiers: bool, + fetch_languages: bool, + db: &'a dyn ReadOnlyDatabase, +} + +impl BookBuilder<'_> { + pub fn sort(mut self, sort: String) -> Self { + self.sort = sort; + self + } + + pub fn timestamp(mut self, timestamp: DateTime) -> Self { + self.timestamp = timestamp; + self + } + + pub fn pubdate(mut self, pubdate: DateTime) -> Self { + self.pubdate = pubdate; + self + } + + pub fn series_index(mut self, series_index: f32) -> Self { + self.series_index = series_index; + self + } + + pub fn author_sort(mut self, author_sort: String) -> Self { + self.author_sort = author_sort; + self + } + + pub fn isbn(mut self, isbn: String) -> Self { + self.isbn = isbn; + self + } + + pub fn lccn(mut self, lccn: String) -> Self { + self.lccn = lccn; + self + } + + pub fn has_cover(mut self, has_cover: bool) -> Self { + self.has_cover = has_cover; + self + } + + pub fn authors(mut self, authors: Vec) -> Self { + self.authors = authors; + self + } + + pub fn publishers(mut self, publishers: Vec) -> Self { + self.publishers = publishers; + self + } + + pub fn tags(mut self, tags: Vec) -> Self { + self.tags = tags; + self + } + + pub fn series(mut self, series: Option) -> Self { + self.series = series; + self + } + + pub fn comments(mut self, comments: Option) -> Self { + self.comments = comments; + self + } + + pub fn rating(mut self, rating: Option) -> Self { + self.rating = rating; + self + } + + pub fn formats(mut self, formats: Vec) -> Self { + self.formats = formats; + self + } + + pub fn identifiers(mut self, identifiers: Vec) -> Self { + self.identifiers = identifiers; + self + } + + pub fn languages(mut self, languages: Vec) -> Self { + self.languages = languages; + self + } + + pub fn fetch_authors(mut self, fetch: bool) -> Self { + self.fetch_authors = fetch; + self + } + + pub fn fetch_publishers(mut self, fetch: bool) -> Self { + self.fetch_publishers = fetch; + self + } + + pub fn fetch_tags(mut self, fetch: bool) -> Self { + self.fetch_tags = fetch; + self + } + + pub fn fetch_series(mut self, fetch: bool) -> Self { + self.fetch_series = fetch; + self + } + + pub fn fetch_comments(mut self, fetch: bool) -> Self { + self.fetch_comments = fetch; + self + } + + pub fn fetch_rating(mut self, fetch: bool) -> Self { + self.fetch_rating = fetch; + self + } + + pub fn fetch_formats(mut self, fetch: bool) -> Self { + self.fetch_formats = fetch; + self + } + + pub fn fetch_identifiers(mut self, fetch: bool) -> Self { + self.fetch_identifiers = fetch; + self + } + + pub fn fetch_languages(mut self, fetch: bool) -> Self { + self.fetch_languages = fetch; + self + } + + pub fn fetch_all(mut self) -> Self { + self.fetch_authors = true; + self.fetch_publishers = true; + self.fetch_tags = true; + self.fetch_series = true; + self.fetch_comments = true; + self.fetch_rating = true; + self.fetch_formats = true; + self.fetch_identifiers = true; + self.fetch_languages = true; + self + } + + pub fn build(mut self) -> crate::error::Result { + let db = self.db; + + // Sequential queries are used here instead of a single complex JOIN. + // This implements a pragmatic trade-off between performance and maintainability. + // + // Why sequential queries instead of a single complex JOIN? + // + // 1. **Cartesian Product Complexity**: Multiple many-to-many JOINs create a cartesian + // product that requires complex aggregation logic to deduplicate results. + // 2. **Maintainability**: Sequential targeted queries are simpler to understand and debug. + // 3. **Performance**: In practice, sequential queries perform well due to SQLite's + // query optimization and caching. The number of queries is fixed (at most 9), + // not dependent on result set size. + // + // When to Consider a Single Query Approach: + // If performance profiling shows N+1 query overhead is significant, consider: + // - Using UNION queries to avoid cartesian products + // - Building a smarter aggregation layer + // - Caching frequently accessed relations + if self.fetch_authors { + self.authors = db.fetch_book_authors(self.id)?; + } + if self.fetch_publishers { + self.publishers = db.fetch_book_publishers(self.id)?; + } + if self.fetch_tags { + self.tags = db.fetch_book_tags(self.id)?; + } + if self.fetch_series { + self.series = db.fetch_book_series(self.id)?; + } + if self.fetch_comments { + self.comments = db.fetch_book_comments(self.id)?; + } + if self.fetch_rating { + self.rating = db.fetch_book_rating(self.id)?; + } + if self.fetch_formats { + self.formats = db.fetch_book_formats(self.id)?; + } + if self.fetch_identifiers { + self.identifiers = db.fetch_book_identifiers(self.id)?; + } + if self.fetch_languages { + self.languages = db.fetch_book_languages(self.id)?; + } + + Ok(Book { + id: self.id, + title: self.title, + path: self.path, + sort: self.sort, + timestamp: self.timestamp, + pubdate: self.pubdate, + series_index: self.series_index, + author_sort: self.author_sort, + isbn: self.isbn, + lccn: self.lccn, + has_cover: self.has_cover, + authors: self.authors, + publishers: self.publishers, + tags: self.tags, + series: self.series, + comments: self.comments, + rating: self.rating, + formats: self.formats, + identifiers: self.identifiers, + languages: self.languages, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Author { + pub id: u32, + pub name: String, + pub sort: String, + pub link: Option, +} + +impl Author { + pub fn new(id: u32, name: String, sort: String) -> Self { + Author { + id, + name, + sort, + link: None, + } + } + + pub fn with_link(mut self, link: String) -> Self { + self.link = Some(link); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Series { + pub id: u32, + pub name: String, +} + +impl Series { + pub fn new(id: u32, name: String) -> Self { + Series { id, name } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Tag { + pub id: u32, + pub name: String, +} + +impl Tag { + pub fn new(id: u32, name: String) -> Self { + Tag { id, name } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Identifier { + pub book_id: u32, + pub kind: String, + pub val: String, +} + +impl Identifier { + pub fn new(book_id: u32, kind: String, val: String) -> Self { + Identifier { book_id, kind, val } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BookMetadata { + pub title: String, + pub authors: Vec, + pub publisher: Option, + pub pubdate: Option>, + pub series: Option, + pub series_index: Option, + pub tags: Vec, + pub comments: Option, + pub rating: Option, + pub isbn: Option, + pub languages: Vec, +} + +impl BookMetadata { + pub fn from_book(book: &Book) -> Self { + BookMetadata { + title: book.title.clone(), + authors: book.authors.iter().map(|a| a.name.clone()).collect(), + publisher: book.publishers.first().cloned(), + pubdate: Some(book.pubdate), + series: book.series.as_ref().map(|s| s.name.clone()), + series_index: if book.series.is_some() { + Some(book.series_index) + } else { + None + }, + tags: book.tags.iter().map(|t| t.name.clone()).collect(), + comments: book.comments.clone(), + rating: book.rating, + isbn: if book.isbn.is_empty() { + None + } else { + Some(book.isbn.clone()) + }, + languages: book.languages.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockDatabase; + + impl ReadOnlyDatabase for MockDatabase { + fn get_book(&self, _book_id: u32) -> crate::Result { + unimplemented!() + } + fn all_books(&self) -> crate::Result> { + unimplemented!() + } + fn fetch_book_authors(&self, _book_id: u32) -> crate::Result> { + Ok(Vec::new()) + } + fn fetch_book_publishers(&self, _book_id: u32) -> crate::Result> { + Ok(Vec::new()) + } + fn fetch_book_tags(&self, _book_id: u32) -> crate::Result> { + Ok(Vec::new()) + } + fn fetch_book_series(&self, _book_id: u32) -> crate::Result> { + Ok(None) + } + fn fetch_book_comments(&self, _book_id: u32) -> crate::Result> { + Ok(None) + } + fn fetch_book_rating(&self, _book_id: u32) -> crate::Result> { + Ok(None) + } + fn fetch_book_formats(&self, _book_id: u32) -> crate::Result> { + Ok(Vec::new()) + } + fn fetch_book_identifiers(&self, _book_id: u32) -> crate::Result> { + Ok(Vec::new()) + } + fn fetch_book_languages(&self, _book_id: u32) -> crate::Result> { + Ok(Vec::new()) + } + } + + #[test] + fn test_book_creation() { + let db = MockDatabase; + let book = Book::builder(1, "Test Book".to_string(), "/path/to/book".to_string(), &db) + .sort("book, test".to_string()) + .has_cover(true) + .build() + .unwrap(); + + assert_eq!(book.id, 1); + assert_eq!(book.title, "Test Book"); + assert_eq!(book.path, "/path/to/book"); + assert!(book.authors.is_empty()); + } + + #[test] + fn test_book_builder_chain() { + let db = MockDatabase; + let authors = vec![Author::new( + 1, + "Test Author".to_string(), + "Author, Test".to_string(), + )]; + let tags = vec![Tag::new(1, "Fiction".to_string())]; + + let book = Book::builder(1, "Test Book".to_string(), "/path/to/book".to_string(), &db) + .sort("book, test".to_string()) + .authors(authors) + .tags(tags) + .has_cover(true) + .build() + .unwrap(); + + assert_eq!(book.authors.len(), 1); + assert_eq!(book.tags.len(), 1); + } + + #[test] + fn test_author_creation() { + let author = Author::new(1, "John Doe".to_string(), "Doe, John".to_string()); + assert_eq!(author.name, "John Doe"); + assert_eq!(author.sort, "Doe, John"); + assert!(author.link.is_none()); + } + + #[test] + fn test_series_creation() { + let series = Series::new(1, "Test Series".to_string()); + assert_eq!(series.name, "Test Series"); + } + + #[test] + fn test_tag_creation() { + let tag = Tag::new(1, "Science Fiction".to_string()); + assert_eq!(tag.name, "Science Fiction"); + } + + #[test] + fn test_identifier_creation() { + let id = Identifier::new(1, "isbn".to_string(), "123-456-789".to_string()); + assert_eq!(id.kind, "isbn"); + assert_eq!(id.val, "123-456-789"); + } + + #[test] + fn test_book_metadata_from_book() { + let db = MockDatabase; + let authors = vec![Author::new( + 1, + "Test Author".to_string(), + "Author, Test".to_string(), + )]; + let tags = vec![Tag::new(1, "Fiction".to_string())]; + let series = Some(Series::new(1, "Test Series".to_string())); + + let book = Book::builder(1, "Test Book".to_string(), "/path/to/book".to_string(), &db) + .sort("book, test".to_string()) + .series_index(1.5) + .isbn("123-456-789".to_string()) + .authors(authors) + .tags(tags) + .series(series) + .rating(Some(4)) + .has_cover(true) + .build() + .unwrap(); + + let metadata = BookMetadata::from_book(&book); + assert_eq!(metadata.title, "Test Book"); + assert_eq!(metadata.authors.len(), 1); + assert_eq!(metadata.rating, Some(4)); + assert_eq!(metadata.series, Some("Test Series".to_string())); + } +} diff --git a/src-tauri/calibre_db/src/schema.rs b/src-tauri/calibre_db/src/schema.rs new file mode 100644 index 0000000..e44e7d0 --- /dev/null +++ b/src-tauri/calibre_db/src/schema.rs @@ -0,0 +1,90 @@ +use crate::error::{CalibreDbError, Result}; +use rusqlite::Connection; +use std::path::Path; + +pub struct DatabaseConnection { + conn: Connection, +} + +impl DatabaseConnection { + pub fn new>(path: P) -> Result { + let conn = Connection::open(path)?; + conn.pragma_update(None, "journal_mode", "WAL")?; + Ok(DatabaseConnection { conn }) + } + + pub fn query_row(&self, query: &str, params: &[&dyn rusqlite::ToSql], f: F) -> Result + where + F: FnOnce(&rusqlite::Row) -> rusqlite::Result, + { + self.conn + .query_row(query, params, f) + .map_err(CalibreDbError::from) + } + + pub fn prepare<'a>(&'a self, query: &str) -> Result> { + self.conn.prepare(query).map_err(CalibreDbError::from) + } +} + +#[cfg(test)] +mod tests { + use crate::CalibreDbError; + + use super::*; + use tempfile::NamedTempFile; + + fn create_test_db() -> Result<(DatabaseConnection, NamedTempFile)> { + let temp_file = NamedTempFile::new().map_err(CalibreDbError::from)?; + let path = temp_file.path().to_path_buf(); + + let conn = Connection::open(&path)?; + + conn.execute( + "CREATE TABLE books ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + sort TEXT, + timestamp TIMESTAMP, + pubdate TIMESTAMP, + series_index REAL, + author_sort TEXT, + isbn TEXT, + lccn TEXT, + path TEXT, + has_cover BOOLEAN DEFAULT 0 + )", + [], + )?; + + conn.execute( + "CREATE TABLE authors ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + sort TEXT + )", + [], + )?; + + drop(conn); + + let db_conn = DatabaseConnection::new(&path)?; + Ok((db_conn, temp_file)) + } + + #[test] + fn test_database_connection_creation() { + let result = create_test_db(); + assert!(result.is_ok()); + } + + #[test] + fn test_query_row_with_select() { + let (db_conn, _temp_file) = create_test_db().unwrap(); + + let result: Result = + db_conn.query_row("SELECT COUNT(*) FROM books", &[], |row| row.get(0)); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + } +} diff --git a/src-tauri/calibre_db/tests/integration_tests.rs b/src-tauri/calibre_db/tests/integration_tests.rs new file mode 100644 index 0000000..fec0310 --- /dev/null +++ b/src-tauri/calibre_db/tests/integration_tests.rs @@ -0,0 +1,518 @@ +#[cfg(test)] +mod integration_tests { + use calibre_db::{CalibreDatabase, ReadOnlyDatabase}; + use rusqlite::Connection; + use tempfile::NamedTempFile; + + fn create_test_database() -> (String, NamedTempFile) { + let temp_file = NamedTempFile::new().expect("Failed to create temp file"); + let path = temp_file.path().to_path_buf(); + let path_str = path.to_string_lossy().to_string(); + + let conn = Connection::open(&path).expect("Failed to open database"); + + conn.execute( + "CREATE TABLE books ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + sort TEXT, + timestamp TIMESTAMP, + pubdate TIMESTAMP, + series_index REAL, + author_sort TEXT, + isbn TEXT, + lccn TEXT, + path TEXT, + has_cover BOOLEAN DEFAULT 0 + )", + [], + ) + .expect("Failed to create books table"); + + conn.execute( + "INSERT INTO books (id, title, sort, timestamp, pubdate, series_index, author_sort, isbn, lccn, path, has_cover) + VALUES + (1, 'The Rust Programming Language', 'rust programming language, the', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z', 1.0, 'Klabnik, Steve', '978-1491927281', '', '/library/book1', 1), + (2, 'Zero to Production in Rust', 'zero to production in rust', '2024-01-02T00:00:00Z', '2024-01-02T00:00:00Z', 1.0, 'Raita, Luca', '978-1617738586', '', '/library/book2', 0)", + [], + ) + .expect("Failed to insert books"); + + conn.execute( + "CREATE TABLE authors ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + sort TEXT + )", + [], + ) + .expect("Failed to create authors table"); + + conn.execute( + "INSERT INTO authors (id, name, sort) VALUES + (1, 'Steve Klabnik', 'Klabnik, Steve'), + (2, 'Luca Raita', 'Raita, Luca')", + [], + ) + .expect("Failed to insert authors"); + + conn.execute( + "CREATE TABLE books_authors_link ( + id INTEGER PRIMARY KEY, + book INTEGER, + author INTEGER + )", + [], + ) + .expect("Failed to create books_authors_link table"); + + conn.execute( + "INSERT INTO books_authors_link (book, author) VALUES + (1, 1), + (2, 2)", + [], + ) + .expect("Failed to insert book-author links"); + + conn.execute( + "CREATE TABLE publishers ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + )", + [], + ) + .expect("Failed to create publishers table"); + + conn.execute( + "INSERT INTO publishers (id, name) VALUES + (1, 'No Starch Press'), + (2, 'Manning')", + [], + ) + .expect("Failed to insert publishers"); + + conn.execute( + "CREATE TABLE books_publishers_link ( + id INTEGER PRIMARY KEY, + book INTEGER, + publisher INTEGER + )", + [], + ) + .expect("Failed to create books_publishers_link table"); + + conn.execute( + "INSERT INTO books_publishers_link (book, publisher) VALUES + (1, 1), + (2, 2)", + [], + ) + .expect("Failed to insert book-publisher links"); + + conn.execute( + "CREATE TABLE tags ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + )", + [], + ) + .expect("Failed to create tags table"); + + conn.execute( + "INSERT INTO tags (id, name) VALUES + (1, 'Programming'), + (2, 'Rust'), + (3, 'Systems'), + (4, 'Web')", + [], + ) + .expect("Failed to insert tags"); + + conn.execute( + "CREATE TABLE books_tags_link ( + id INTEGER PRIMARY KEY, + book INTEGER, + tag INTEGER + )", + [], + ) + .expect("Failed to create books_tags_link table"); + + conn.execute( + "INSERT INTO books_tags_link (book, tag) VALUES + (1, 1), (1, 2), (1, 3), + (2, 1), (2, 2), (2, 4)", + [], + ) + .expect("Failed to insert book-tag links"); + + conn.execute( + "CREATE TABLE series ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + )", + [], + ) + .expect("Failed to create series table"); + + conn.execute( + "INSERT INTO series (id, name) VALUES + (1, 'Official Rust Book')", + [], + ) + .expect("Failed to insert series"); + + conn.execute( + "CREATE TABLE books_series_link ( + id INTEGER PRIMARY KEY, + book INTEGER, + series INTEGER + )", + [], + ) + .expect("Failed to create books_series_link table"); + + conn.execute( + "INSERT INTO books_series_link (book, series) VALUES (1, 1)", + [], + ) + .expect("Failed to insert book-series links"); + + conn.execute( + "CREATE TABLE comments ( + id INTEGER PRIMARY KEY, + book INTEGER, + text TEXT + )", + [], + ) + .expect("Failed to create comments table"); + + conn.execute( + "INSERT INTO comments (book, text) VALUES + (1, 'Essential reading for Rust developers'), + (2, 'Great practical guide for production systems')", + [], + ) + .expect("Failed to insert comments"); + + conn.execute( + "CREATE TABLE ratings ( + id INTEGER PRIMARY KEY, + rating INTEGER + )", + [], + ) + .expect("Failed to create ratings table"); + + conn.execute( + "INSERT INTO ratings (id, rating) VALUES + (1, 10), + (2, 8)", + [], + ) + .expect("Failed to insert ratings"); + + conn.execute( + "CREATE TABLE books_ratings_link ( + id INTEGER PRIMARY KEY, + book INTEGER, + rating INTEGER + )", + [], + ) + .expect("Failed to create books_ratings_link table"); + + conn.execute( + "INSERT INTO books_ratings_link (book, rating) VALUES + (1, 1), + (2, 2)", + [], + ) + .expect("Failed to insert book-rating links"); + + conn.execute( + "CREATE TABLE data ( + id INTEGER PRIMARY KEY, + book INTEGER, + format TEXT + )", + [], + ) + .expect("Failed to create data table"); + + conn.execute( + "INSERT INTO data (book, format) VALUES + (1, 'EPUB'), (1, 'PDF'), + (2, 'EPUB'), (2, 'MOBI')", + [], + ) + .expect("Failed to insert formats"); + + conn.execute( + "CREATE TABLE identifiers ( + id INTEGER PRIMARY KEY, + book INTEGER, + type TEXT, + val TEXT + )", + [], + ) + .expect("Failed to create identifiers table"); + + conn.execute( + "INSERT INTO identifiers (book, type, val) VALUES + (1, 'isbn', '978-1491927281'), + (2, 'isbn', '978-1617738586')", + [], + ) + .expect("Failed to insert identifiers"); + + conn.execute( + "CREATE TABLE languages ( + id INTEGER PRIMARY KEY, + lang_code TEXT NOT NULL + )", + [], + ) + .expect("Failed to create languages table"); + + conn.execute( + "INSERT INTO languages (id, lang_code) VALUES + (1, 'en'), + (2, 'es')", + [], + ) + .expect("Failed to insert languages"); + + conn.execute( + "CREATE TABLE books_languages_link ( + id INTEGER PRIMARY KEY, + book INTEGER, + lang_code INTEGER, + item_order INTEGER DEFAULT 0 + )", + [], + ) + .expect("Failed to create books_languages_link table"); + + conn.execute( + "INSERT INTO books_languages_link (book, lang_code, item_order) VALUES + (1, 1, 0), (1, 2, 1), + (2, 1, 0)", + [], + ) + .expect("Failed to insert book-language links"); + + drop(conn); + + (path_str, temp_file) + } + + #[test] + fn test_open_database() { + let (path, _temp) = create_test_database(); + let result = CalibreDatabase::open(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_get_single_book() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let book = db.get_book(1).expect("Failed to get book"); + assert_eq!(book.id, 1); + assert_eq!(book.title, "The Rust Programming Language"); + assert_eq!(book.isbn, "978-1491927281"); + } + + #[test] + fn test_get_book_with_authors() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let book = db.get_book(1).expect("Failed to get book"); + assert_eq!(book.authors.len(), 1); + assert_eq!(book.authors[0].name, "Steve Klabnik"); + assert_eq!(book.authors[0].sort, "Klabnik, Steve"); + } + + #[test] + fn test_get_book_with_publishers() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let book = db.get_book(1).expect("Failed to get book"); + assert_eq!(book.publishers.len(), 1); + assert_eq!(book.publishers[0], "No Starch Press"); + } + + #[test] + fn test_get_book_with_tags() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let book = db.get_book(1).expect("Failed to get book"); + assert_eq!(book.tags.len(), 3); + assert_eq!(book.tags[0].name, "Programming"); + assert_eq!(book.tags[1].name, "Rust"); + assert_eq!(book.tags[2].name, "Systems"); + } + + #[test] + fn test_get_book_with_series() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let book = db.get_book(1).expect("Failed to get book"); + assert!(book.series.is_some()); + assert_eq!(book.series.unwrap().name, "Official Rust Book"); + } + + #[test] + fn test_get_book_with_comments() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let book = db.get_book(1).expect("Failed to get book"); + assert!(book.comments.is_some()); + assert_eq!( + book.comments.unwrap(), + "Essential reading for Rust developers" + ); + } + + #[test] + fn test_get_book_with_rating() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let book = db.get_book(1).expect("Failed to get book"); + assert!(book.rating.is_some()); + assert_eq!(book.rating.unwrap(), 5); + } + + #[test] + fn test_get_book_with_formats() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let book = db.get_book(1).expect("Failed to get book"); + assert_eq!(book.formats.len(), 2); + assert!(book.formats.contains(&"EPUB".to_string())); + assert!(book.formats.contains(&"PDF".to_string())); + } + + #[test] + fn test_get_book_with_identifiers() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let book = db.get_book(1).expect("Failed to get book"); + assert_eq!(book.identifiers.len(), 1); + assert_eq!(book.identifiers[0].kind, "isbn"); + assert_eq!(book.identifiers[0].val, "978-1491927281"); + } + + #[test] + fn test_get_book_with_languages() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let book = db.get_book(1).expect("Failed to get book"); + assert_eq!(book.languages.len(), 2); + assert!(book.languages.contains(&"en".to_string())); + assert!(book.languages.contains(&"es".to_string())); + } + + #[test] + fn test_get_all_books() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let books = db.all_books().expect("Failed to get all books"); + assert_eq!(books.len(), 2); + + assert_eq!(books[0].id, 1); + assert_eq!(books[1].id, 2); + } + + #[test] + fn test_all_books_ordered_by_sort() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let books = db.all_books().expect("Failed to get all books"); + assert_eq!(books.len(), 2); + assert_eq!(books[0].sort, "rust programming language, the"); + assert_eq!(books[1].sort, "zero to production in rust"); + } + + #[test] + fn test_get_book_nonexistent() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let result = db.get_book(9999); + assert!(result.is_err()); + } + + #[test] + fn test_book_without_series() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let book = db.get_book(2).expect("Failed to get book"); + assert!(book.series.is_none()); + } + + #[test] + fn test_multiple_authors_per_book() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let conn = Connection::open(&path).expect("Failed to open database for modification"); + + conn.execute( + "INSERT INTO authors (id, name, sort) VALUES (3, 'Co Author', 'Author, Co')", + [], + ) + .expect("Failed to insert author"); + + conn.execute( + "INSERT INTO books_authors_link (book, author) VALUES (1, 3)", + [], + ) + .expect("Failed to insert author link"); + + drop(conn); + + let book = db.get_book(1).expect("Failed to get book"); + assert_eq!(book.authors.len(), 2); + } + + #[test] + fn test_book_complete_metadata() { + let (path, _temp) = create_test_database(); + let db = CalibreDatabase::open(&path).expect("Failed to open database"); + + let book = db.get_book(1).expect("Failed to get book"); + + assert_eq!(book.title, "The Rust Programming Language"); + assert_eq!(book.author_sort, "Klabnik, Steve"); + assert_eq!(book.series_index, 1.0); + assert!(book.has_cover); + assert_eq!(book.path, "/library/book1"); + assert!(!book.isbn.is_empty()); + assert_eq!(book.authors.len(), 1); + assert_eq!(book.publishers.len(), 1); + assert_eq!(book.tags.len(), 3); + assert!(book.series.is_some()); + assert!(book.comments.is_some()); + assert!(book.rating.is_some()); + assert_eq!(book.formats.len(), 2); + assert!(!book.identifiers.is_empty()); + assert_eq!(book.languages.len(), 2); + } +} diff --git a/src-tauri/src/archive/commands.rs b/src-tauri/src/archive/commands.rs index 4c60cbd..2457a37 100644 --- a/src-tauri/src/archive/commands.rs +++ b/src-tauri/src/archive/commands.rs @@ -2,9 +2,9 @@ use crate::archive::manager::start_archive_watch_for_creation; use super::manager::{start_archive_watcher, stop_archive_watcher}; use super::reader::{get_file_data, read_archive, stream_file_data_from_archive}; -use super::types::{LoadCbzResponse, ToErrorResponse, is_image_file}; +use super::types::{is_image_file, LoadCbzResponse, ToErrorResponse}; use super::writer::{delete_comicinfo_xml, save_comicinfo_xml_impl, save_page_settings_impl}; -use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine}; use log::debug; use serde::Serialize; use std::collections::HashMap; diff --git a/src-tauri/src/archive/mod.rs b/src-tauri/src/archive/mod.rs index 8ecd7d1..b333d9a 100644 --- a/src-tauri/src/archive/mod.rs +++ b/src-tauri/src/archive/mod.rs @@ -17,8 +17,8 @@ mod tests { use crate::comicinfo::{ComicInfo, ComicPageType}; use std::collections::HashMap; use std::io::{Read, Write}; - use zip::CompressionMethod as ZipCompressionMethod; use zip::write::FileOptions as ZipFileOptions; + use zip::CompressionMethod as ZipCompressionMethod; fn test_path(name: &str) -> String { let mut dir = std::env::temp_dir(); diff --git a/src-tauri/src/archive/reader.rs b/src-tauri/src/archive/reader.rs index 8cc8836..a87fb37 100644 --- a/src-tauri/src/archive/reader.rs +++ b/src-tauri/src/archive/reader.rs @@ -93,8 +93,8 @@ mod tests { use super::*; use std::io::Write; use std::sync::{Arc, Mutex}; - use zip::ZipWriter; use zip::write::FileOptions; + use zip::ZipWriter; struct TestArchive { _temp_file: tempfile::NamedTempFile, diff --git a/src-tauri/src/archive/watcher.rs b/src-tauri/src/archive/watcher.rs index 24aad1a..ffe0054 100644 --- a/src-tauri/src/archive/watcher.rs +++ b/src-tauri/src/archive/watcher.rs @@ -1,9 +1,9 @@ use crate::archive::event::{ArchiveEventEmitter, ArchiveEventType}; use log::debug; -use notify::{RecommendedWatcher, RecursiveMode, Result as NotifyResult, Watcher, event::Event}; +use notify::{event::Event, RecommendedWatcher, RecursiveMode, Result as NotifyResult, Watcher}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, mpsc}; +use std::sync::{mpsc, Arc}; use std::thread; use std::time::{Duration, Instant}; diff --git a/src-tauri/src/archive/writer.rs b/src-tauri/src/archive/writer.rs index 6485a67..1d8cc1d 100644 --- a/src-tauri/src/archive/writer.rs +++ b/src-tauri/src/archive/writer.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use std::fs; use std::io::{BufReader, BufWriter, Write}; -use zip::CompressionMethod; use zip::write::FileOptions; +use zip::CompressionMethod; use crate::comicinfo::{ComicInfo, ComicPageInfo, ComicPageType, Pages}; use log::debug; @@ -265,8 +265,8 @@ mod tests { use crate::comicinfo::{ComicInfo, ComicPageType}; use std::collections::HashMap; use std::io::{Read, Write}; - use zip::CompressionMethod as ZipCompressionMethod; use zip::write::FileOptions as ZipFileOptions; + use zip::CompressionMethod as ZipCompressionMethod; fn test_path(name: &str) -> String { let mut dir = std::env::temp_dir(); diff --git a/src-tauri/src/bin/create_test_cbz.rs b/src-tauri/src/bin/create_test_cbz.rs index 53dcaea..ec84b25 100644 --- a/src-tauri/src/bin/create_test_cbz.rs +++ b/src-tauri/src/bin/create_test_cbz.rs @@ -1,6 +1,6 @@ use std::fs::{self, File}; use std::io::{self, Write}; -use zip::{ZipWriter, write::FileOptions}; +use zip::{write::FileOptions, ZipWriter}; fn main() -> io::Result<()> { let output_path = "../tmp/test_invalid_comic.cbz"; diff --git a/src-tauri/src/bin/create_test_missing_comicinfo.rs b/src-tauri/src/bin/create_test_missing_comicinfo.rs index 737f716..46640ea 100644 --- a/src-tauri/src/bin/create_test_missing_comicinfo.rs +++ b/src-tauri/src/bin/create_test_missing_comicinfo.rs @@ -1,6 +1,6 @@ use std::fs::{self, File}; use std::io::{self, Write}; -use zip::{ZipWriter, write::FileOptions}; +use zip::{write::FileOptions, ZipWriter}; fn main() -> io::Result<()> { let output_path = "../tmp/test_missing_comicinfo.cbz"; diff --git a/src-tauri/src/comicinfo/info.rs b/src-tauri/src/comicinfo/info.rs index bb399a2..75dcf8d 100644 --- a/src-tauri/src/comicinfo/info.rs +++ b/src-tauri/src/comicinfo/info.rs @@ -1,7 +1,8 @@ use super::page::Pages; use super::types::{ - AgeRating, Manga, YesNo, default_age_rating, default_manga, default_minus_one, default_yes_no, - is_minus_one, is_unknown_age_rating, is_unknown_manga, is_unknown_yes_no, is_zero_i32, + default_age_rating, default_manga, default_minus_one, default_yes_no, is_minus_one, + is_unknown_age_rating, is_unknown_manga, is_unknown_yes_no, is_zero_i32, AgeRating, Manga, + YesNo, }; use crate::archive::types::{ErrorResponse, ErrorResponseType, ToErrorResponse}; use quick_xml::events::Event; diff --git a/src-tauri/src/comicinfo/mod.rs b/src-tauri/src/comicinfo/mod.rs index 661fc18..7665b67 100644 --- a/src-tauri/src/comicinfo/mod.rs +++ b/src-tauri/src/comicinfo/mod.rs @@ -3,7 +3,7 @@ pub mod info; pub mod page; pub mod types; -pub use info::{ComicInfo, get_bookmarked_pages}; +pub use info::{get_bookmarked_pages, ComicInfo}; pub use page::{ComicPageInfo, Pages}; pub use types::ComicPageType; @@ -112,13 +112,11 @@ A secret double life begins—one he can't tell anyone about! Some("AKB49: The Rules Against Love".to_string()) ); assert_eq!(comic.number, Some("1.0".to_string())); - assert!( - comic - .summary - .as_ref() - .unwrap() - .contains("A boy joins AKB48") - ); + assert!(comic + .summary + .as_ref() + .unwrap() + .contains("A boy joins AKB48")); assert_eq!(comic.year, 2010); assert_eq!(comic.month, 12); assert_eq!(comic.day, 17); diff --git a/src-tauri/src/comicinfo/page.rs b/src-tauri/src/comicinfo/page.rs index 0ffc89c..1ef8a5f 100644 --- a/src-tauri/src/comicinfo/page.rs +++ b/src-tauri/src/comicinfo/page.rs @@ -1,4 +1,4 @@ -use super::types::{ComicPageType, default_minus_one, is_false, is_minus_one, is_zero_i64}; +use super::types::{default_minus_one, is_false, is_minus_one, is_zero_i64, ComicPageType}; use serde::Deserialize; mod comic_page_type_option_serde { @@ -193,8 +193,8 @@ impl serde::Serialize for Pages { #[cfg(test)] mod tests { use super::*; - use quick_xml::Reader; use quick_xml::events::Event; + use quick_xml::Reader; #[test] fn test_page_attribute_order() { diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs new file mode 100644 index 0000000..cb64f2d --- /dev/null +++ b/src-tauri/src/error.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum AppError { + LibraryError(String), + BookNotFound(String), + DatabaseError(String), + IoError(String), + ValidationError(String), +} + +impl std::fmt::Display for AppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AppError::LibraryError(msg) => write!(f, "Library error: {}", msg), + AppError::BookNotFound(msg) => write!(f, "Book not found: {}", msg), + AppError::DatabaseError(msg) => write!(f, "Database error: {}", msg), + AppError::IoError(msg) => write!(f, "IO error: {}", msg), + AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg), + } + } +} + +impl std::error::Error for AppError {} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bbad0da..494f9a7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,15 @@ #[cfg_attr(mobile, tauri::mobile_entry_point)] mod archive; mod comicinfo; +pub mod error; +pub mod library; + +use library::commands::LibraryState; pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + .manage(LibraryState::default()) .invoke_handler(tauri::generate_handler![ archive::load_cbz, archive::unload_cbz, @@ -19,6 +24,11 @@ pub fn run() { comicinfo::commands::get_bookmarked_pages, comicinfo::commands::validate_comicinfo_xml, comicinfo::commands::format_comicinfo_xml, + library::commands::library_open, + library::commands::library_get_all_books, + library::commands::library_get_book, + library::commands::library_get_book_count, + library::commands::library_stream_book_covers, ]) .setup(|app| { if cfg!(debug_assertions) { diff --git a/src-tauri/src/library/base.rs b/src-tauri/src/library/base.rs new file mode 100644 index 0000000..6c9b885 --- /dev/null +++ b/src-tauri/src/library/base.rs @@ -0,0 +1,17 @@ +use async_trait::async_trait; + +use crate::error::AppError; +use crate::library::models::Book; + +#[async_trait] +pub trait BookLibrary: Send + Sync { + async fn get_all_books(&self) -> Result, AppError>; + + async fn get_book(&self, book_id: u32) -> Result; + + async fn get_book_count(&self) -> Result; + + async fn get_book_cover(&self, book_id: u32) -> Result, AppError>; + + fn clone_box(&self) -> Box; +} diff --git a/src-tauri/src/library/calibre.rs b/src-tauri/src/library/calibre.rs new file mode 100644 index 0000000..d41f37a --- /dev/null +++ b/src-tauri/src/library/calibre.rs @@ -0,0 +1,131 @@ +use async_trait::async_trait; +use calibre_db::{CalibreDatabase, ReadOnlyDatabase}; +use std::path::PathBuf; +use std::sync::Arc; + +use super::base::BookLibrary; +use super::models::Book; +use crate::error::AppError; + +const COVER_FILE_NAME: &str = "cover.jpg"; + +#[derive(Clone)] +pub struct CalibreLibrary { + db: Arc, + library_path: PathBuf, +} + +impl CalibreLibrary { + pub fn new(library_path: PathBuf) -> Result { + let metadata_db_path = library_path.join("metadata.db"); + + if !metadata_db_path.exists() { + return Err(AppError::LibraryError(format!( + "Calibre database not found at: {}", + metadata_db_path.display() + ))); + } + + let db = CalibreDatabase::open(metadata_db_path) + .map_err(|e| AppError::LibraryError(e.to_string()))?; + + Ok(CalibreLibrary { + db: Arc::new(db), + library_path, + }) + } + + fn get_cover_path(&self, book_path: &str) -> PathBuf { + self.library_path.join(book_path).join(COVER_FILE_NAME) + } +} + +// SAFETY: CalibreDatabase wraps rusqlite::Connection, which is thread-safe. +// rusqlite uses SQLite's built-in locking mechanisms to ensure safe concurrent access. +// All database operations in this crate are read-only, preventing data races. +// The Arc pattern safely shares the connection across async tasks. +unsafe impl Send for CalibreLibrary {} +unsafe impl Sync for CalibreLibrary {} + +#[async_trait] +impl BookLibrary for CalibreLibrary { + async fn get_all_books(&self) -> Result, AppError> { + let calibre_books = self + .db + .all_books() + .map_err(|e| AppError::LibraryError(e.to_string()))?; + + Ok(calibre_books.into_iter().map(Book::from).collect()) + } + + async fn get_book(&self, book_id: u32) -> Result { + let calibre_book = self + .db + .get_book(book_id) + .map_err(|e| AppError::LibraryError(e.to_string()))?; + + Ok(Book::from(calibre_book)) + } + + async fn get_book_count(&self) -> Result { + self.db + .all_books() + .map(|books| books.len() as u32) + .map_err(|e| AppError::LibraryError(e.to_string())) + } + + async fn get_book_cover(&self, book_id: u32) -> Result, AppError> { + let calibre_book = self + .db + .get_book(book_id) + .map_err(|e| AppError::LibraryError(e.to_string()))?; + + if !calibre_book.has_cover { + return Err(AppError::BookNotFound(format!( + "Book {} has no cover", + book_id + ))); + } + + let cover_path = self.get_cover_path(&calibre_book.path); + + if !cover_path.exists() { + return Err(AppError::IoError(format!( + "Cover file not found at: {}", + cover_path.display() + ))); + } + + std::fs::read(&cover_path) + .map_err(|e| AppError::IoError(format!("Failed to read cover: {}", e))) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_invalid_library_path() { + let result = CalibreLibrary::new(PathBuf::from("/nonexistent/path")); + assert!(result.is_err()); + } + + #[test] + fn test_cover_path_construction() { + let library_path = PathBuf::from("/test/library"); + let book_path = "Author Name/Book Title"; + + let cover_path = library_path.join(book_path).join(COVER_FILE_NAME); + + let path_str = cover_path.to_string_lossy(); + + assert!(path_str.contains("cover.jpg")); + assert!(path_str.contains("Author Name")); + assert!(path_str.contains("Book Title")); + } +} diff --git a/src-tauri/src/library/commands.rs b/src-tauri/src/library/commands.rs new file mode 100644 index 0000000..6e36add --- /dev/null +++ b/src-tauri/src/library/commands.rs @@ -0,0 +1,192 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use tauri::ipc::Channel; +use tauri::State; + +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine}; +use log::debug; +use serde::Serialize; + +use super::models::Book; +use super::{BookLibrary, CalibreLibrary}; + +pub struct LibraryState { + library: Arc>>>, +} + +impl LibraryState { + pub fn new() -> Self { + Self { + library: Arc::new(Mutex::new(None)), + } + } +} + +impl Default for LibraryState { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug)] +enum LibraryUri { + Calibre(PathBuf), +} + +impl LibraryUri { + fn parse(uri: &str) -> Result { + if let Some(path) = uri.strip_prefix("calibre://") { + Ok(LibraryUri::Calibre(PathBuf::from(path))) + } else { + Err(format!( + "Unknown library URI scheme. Expected 'calibre://', got '{}'", + uri + )) + } + } +} + +#[tauri::command] +pub async fn library_open(uri: String, state: State<'_, LibraryState>) -> Result<(), String> { + let library_uri = LibraryUri::parse(&uri)?; + + let library: Box = match library_uri { + LibraryUri::Calibre(path) => { + let calibre_library = CalibreLibrary::new(path).map_err(|e| e.to_string())?; + Box::new(calibre_library) + } + }; + + let mut library_guard = state + .library + .lock() + .map_err(|e| format!("Failed to acquire lock: {}", e))?; + + *library_guard = Some(library); + + Ok(()) +} + +#[tauri::command] +pub async fn library_get_all_books(state: State<'_, LibraryState>) -> Result, String> { + let library = { + let library_guard = state + .library + .lock() + .map_err(|e| format!("Failed to acquire lock: {}", e))?; + + library_guard + .as_ref() + .ok_or_else(|| "No library opened. Call library_open first.".to_string())? + .clone_box() + }; + + library.get_all_books().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn library_get_book( + book_id: u32, + state: State<'_, LibraryState>, +) -> Result { + let library = { + let library_guard = state + .library + .lock() + .map_err(|e| format!("Failed to acquire lock: {}", e))?; + + library_guard + .as_ref() + .ok_or_else(|| "No library opened. Call library_open first.".to_string())? + .clone_box() + }; + + library.get_book(book_id).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn library_get_book_count(state: State<'_, LibraryState>) -> Result { + let library = { + let library_guard = state + .library + .lock() + .map_err(|e| format!("Failed to acquire lock: {}", e))?; + + library_guard + .as_ref() + .ok_or_else(|| "No library opened. Call library_open first.".to_string())? + .clone_box() + }; + + library.get_book_count().await.map_err(|e| e.to_string()) +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase", tag = "event", content = "data")] +pub enum CoverStreamEvent { + Started { total_books: usize }, + Cover { book_id: u32, data_base64: String }, + Error { book_id: u32, message: String }, + Finished, +} + +#[tauri::command] +pub async fn library_stream_book_covers( + book_ids: Vec, + state: State<'_, LibraryState>, + on_event: Channel, +) -> Result<(), String> { + let library = { + let library_guard = state + .library + .lock() + .map_err(|e| format!("Failed to acquire lock: {}", e))?; + + library_guard + .as_ref() + .ok_or_else(|| "No library opened. Call library_open first.".to_string())? + .clone_box() + }; + + tauri::async_runtime::spawn(async move { + let total = book_ids.len(); + + if let Err(e) = on_event.send(CoverStreamEvent::Started { total_books: total }) { + debug!("Failed to send Started event: {}", e); + return; + } + + for book_id in book_ids { + match library.get_book_cover(book_id).await { + Ok(cover_data) => { + let data_base64 = BASE64_STANDARD.encode(&cover_data); + + if let Err(e) = on_event.send(CoverStreamEvent::Cover { + book_id, + data_base64, + }) { + debug!("Failed to send Cover event for book {}: {}", book_id, e); + } + } + Err(e) => { + if let Err(send_err) = on_event.send(CoverStreamEvent::Error { + book_id, + message: e.to_string(), + }) { + debug!( + "Failed to send Error event for book {}: {}", + book_id, send_err + ); + } + } + } + } + + if let Err(e) = on_event.send(CoverStreamEvent::Finished) { + debug!("Failed to send Finished event: {}", e); + } + }); + + Ok(()) +} diff --git a/src-tauri/src/library/mod.rs b/src-tauri/src/library/mod.rs new file mode 100644 index 0000000..0834006 --- /dev/null +++ b/src-tauri/src/library/mod.rs @@ -0,0 +1,8 @@ +pub mod base; +pub mod calibre; +pub mod commands; +pub mod models; + +pub use base::BookLibrary; +pub use calibre::CalibreLibrary; +pub use models::Book; diff --git a/src-tauri/src/library/models.rs b/src-tauri/src/library/models.rs new file mode 100644 index 0000000..861554d --- /dev/null +++ b/src-tauri/src/library/models.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; + +/// Library-agnostic Book structure that can be returned to the frontend. +/// Only includes fields that are actually used by the frontend. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Book { + pub id: u32, + pub title: String, + pub pubdate: String, + pub isbn: String, + pub authors: Vec, + pub publishers: Vec, + pub tags: Vec, + pub series: Option, + pub rating: Option, + pub formats: Vec, + pub languages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Author { + pub id: u32, + pub name: String, + pub sort: String, + pub link: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Series { + pub id: u32, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Tag { + pub id: u32, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Identifier { + pub book_id: u32, + pub kind: String, + pub val: String, +} + +impl From for Book { + fn from(book: calibre_db::Book) -> Self { + Book { + id: book.id, + title: book.title, + pubdate: book.pubdate.to_rfc3339(), + isbn: book.isbn, + authors: book.authors.into_iter().map(Author::from).collect(), + publishers: book.publishers, + tags: book.tags.into_iter().map(Tag::from).collect(), + series: book.series.map(Series::from), + rating: book.rating, + formats: book.formats, + languages: book.languages, + } + } +} + +impl From for Author { + fn from(author: calibre_db::Author) -> Self { + Author { + id: author.id, + name: author.name, + sort: author.sort, + link: author.link, + } + } +} + +impl From for Series { + fn from(series: calibre_db::Series) -> Self { + Series { + id: series.id, + name: series.name, + } + } +} + +impl From for Tag { + fn from(tag: calibre_db::Tag) -> Self { + Tag { + id: tag.id, + name: tag.name, + } + } +} + +impl From for Identifier { + fn from(identifier: calibre_db::Identifier) -> Self { + Identifier { + book_id: identifier.book_id, + kind: identifier.kind, + val: identifier.val, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_author_conversion() { + let calibre_author = calibre_db::Author { + id: 1, + name: "Test Author".to_string(), + sort: "Author, Test".to_string(), + link: Some("http://example.com".to_string()), + }; + + let agnostic_author: Author = calibre_author.into(); + + assert_eq!(agnostic_author.id, 1); + assert_eq!(agnostic_author.name, "Test Author"); + assert_eq!(agnostic_author.sort, "Author, Test"); + assert_eq!(agnostic_author.link, Some("http://example.com".to_string())); + } + + #[test] + fn test_series_conversion() { + let calibre_series = calibre_db::Series { + id: 5, + name: "Test Series".to_string(), + }; + + let agnostic_series: Series = calibre_series.into(); + + assert_eq!(agnostic_series.id, 5); + assert_eq!(agnostic_series.name, "Test Series"); + } + + #[test] + fn test_tag_conversion() { + let calibre_tag = calibre_db::Tag { + id: 3, + name: "Fiction".to_string(), + }; + + let agnostic_tag: Tag = calibre_tag.into(); + + assert_eq!(agnostic_tag.id, 3); + assert_eq!(agnostic_tag.name, "Fiction"); + } + + #[test] + fn test_identifier_conversion() { + let calibre_identifier = calibre_db::Identifier { + book_id: 10, + kind: "isbn".to_string(), + val: "1234567890".to_string(), + }; + + let agnostic_identifier: Identifier = calibre_identifier.into(); + + assert_eq!(agnostic_identifier.book_id, 10); + assert_eq!(agnostic_identifier.kind, "isbn"); + assert_eq!(agnostic_identifier.val, "1234567890"); + } +} diff --git a/src-tauri/tests/lib_tests.rs b/src-tauri/tests/lib_tests.rs new file mode 100644 index 0000000..f527423 --- /dev/null +++ b/src-tauri/tests/lib_tests.rs @@ -0,0 +1,30 @@ +#[cfg(test)] +mod tests { + use app_lib::error::AppError; + + #[test] + fn test_app_error_creation() { + let err = AppError::LibraryError("test error".to_string()); + assert!(matches!(err, AppError::LibraryError(_))); + } + + #[test] + fn test_app_error_display() { + let err = AppError::BookNotFound("book 123".to_string()); + let msg = format!("{}", err); + assert!(msg.contains("book 123")); + } + + #[test] + fn test_app_error_variants() { + let errors = [ + AppError::LibraryError("lib error".to_string()), + AppError::BookNotFound("not found".to_string()), + AppError::DatabaseError("db error".to_string()), + AppError::IoError("io error".to_string()), + AppError::ValidationError("validation error".to_string()), + ]; + + assert_eq!(errors.len(), 5); + } +} diff --git a/src/__tests__/components/BooksTable.test.tsx b/src/__tests__/components/BooksTable.test.tsx new file mode 100644 index 0000000..16175c6 --- /dev/null +++ b/src/__tests__/components/BooksTable.test.tsx @@ -0,0 +1,287 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { BooksTable } from "@/components/BooksTable"; +import { Book } from "@/types/book"; +import { ColumnConfig } from "@/hooks/useTableColumns"; +import { renderWithProviders } from "@/test-utils/testUtils"; + +const mockBook: Book = { + id: 1, + title: "Test Book", + pubdate: "2024-01-01T00:00:00Z", + isbn: "123-456-789", + authors: [{ id: 1, name: "Test Author", sort: "Author, Test" }], + publishers: ["Test Publisher"], + tags: [ + { id: 1, name: "Fiction" }, + { id: 2, name: "Adventure" }, + ], + series: { id: 1, name: "Test Series" }, + rating: 4, + formats: ["EPUB", "PDF"], + languages: ["en", "es"], +}; + +const mockColumns: ColumnConfig[] = [ + { id: "cover", label: "Cover", visible: true, order: 0 }, + { id: "title", label: "Title", visible: true, order: 1 }, + { id: "authors", label: "Authors", visible: true, order: 2 }, + { id: "tags", label: "Tags", visible: true, order: 3 }, + { id: "isbn", label: "ISBN", visible: false, order: 4 }, +]; + +const mockBook2 = { ...mockBook, id: 2, title: "Another Book" }; +const mockBook3 = { ...mockBook, id: 3, title: "Different Title" }; +const mockBookTagsFormatted = mockBook.tags.map((t) => t.name).join(", "); + +describe("BooksTable", () => { + const mockToggleColumnVisibility = jest.fn(); + const mockReorderColumns = jest.fn(); + const mockResetColumns = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders books in a table", () => { + renderWithProviders( + , + ); + + expect(screen.getByTestId("book-row-1")).toBeInTheDocument(); + expect(screen.getByText(mockBook.title)).toBeInTheDocument(); + expect(screen.getByText(mockBook.authors[0].name)).toBeInTheDocument(); + }); + + it("displays only visible columns", () => { + renderWithProviders( + , + ); + + expect(screen.getByTestId("column-header-title")).toBeInTheDocument(); + expect(screen.getByTestId("column-header-authors")).toBeInTheDocument(); + expect(screen.queryByTestId("column-header-isbn")).not.toBeInTheDocument(); + }); + + it("filters books by search query", async () => { + renderWithProviders( + , + ); + + const searchInput = screen.getByTestId( + "books-search-input", + ) as HTMLInputElement; + await userEvent.type(searchInput, "Another"); + + expect(screen.getByText(mockBook2.title)).toBeInTheDocument(); + expect(screen.queryByText(mockBook.title)).not.toBeInTheDocument(); + }); + + it("shows all books when search query is empty", () => { + renderWithProviders( + , + ); + + expect(screen.getByText(mockBook.title)).toBeInTheDocument(); + expect(screen.getByText(mockBook2.title)).toBeInTheDocument(); + }); + + it("supports advanced field-specific search", async () => { + renderWithProviders( + , + ); + + const searchInput = screen.getByTestId( + "books-search-input", + ) as HTMLInputElement; + await userEvent.type(searchInput, `title:"${mockBook.title}"`); + + expect(screen.getByText(mockBook.title)).toBeInTheDocument(); + expect(screen.queryByText(mockBook3.title)).not.toBeInTheDocument(); + }); + + it("opens column menu when Columns button is clicked", async () => { + renderWithProviders( + , + ); + + const columnsButton = screen.getByTestId("columns-menu-button"); + await userEvent.click(columnsButton); + + expect(screen.getByTestId("columns-menu")).toBeInTheDocument(); + }); + + it("toggles column visibility from menu", async () => { + renderWithProviders( + , + ); + + const columnsButton = screen.getByTestId("columns-menu-button"); + await userEvent.click(columnsButton); + + // Find the ISBN label and click it (this will trigger the MenuItem click) + const isbnLabel = await screen.findByText("ISBN"); + await userEvent.click(isbnLabel); + + expect(mockToggleColumnVisibility).toHaveBeenCalledWith("isbn"); + }); + + it("calls onResetColumns when reset button is clicked", async () => { + renderWithProviders( + , + ); + + const columnsButton = screen.getByTestId("columns-menu-button"); + await userEvent.click(columnsButton); + + const resetButton = screen.getByTestId("reset-columns-button"); + await userEvent.click(resetButton); + + expect(mockResetColumns).toHaveBeenCalled(); + }); + + it("displays empty state when no books match search", async () => { + renderWithProviders( + , + ); + + const searchInput = screen.getByTestId( + "books-search-input", + ) as HTMLInputElement; + await userEvent.type(searchInput, "NonexistentBook"); + + expect(screen.getByTestId("empty-state")).toBeInTheDocument(); + }); + + it("displays error when error prop is provided", () => { + const errorMessage = "Test error message"; + renderWithProviders( + , + ); + + expect(screen.getByTestId("error-message")).toHaveTextContent(errorMessage); + }); + + it("displays loading message when isLoading is true", () => { + renderWithProviders( + , + ); + + expect(screen.getByTestId("loading-state")).toBeInTheDocument(); + }); + + it("displays book count at the bottom", () => { + const books = [mockBook, { ...mockBook, id: 2 }]; + renderWithProviders( + , + ); + + const bookCountElement = screen.getByTestId("book-count"); + expect(bookCountElement).toHaveTextContent( + `Showing ${books.length} of ${books.length} books`, + ); + }); + + it("formats array values with comma separation", () => { + renderWithProviders( + , + ); + + expect(screen.getByText(mockBookTagsFormatted)).toBeInTheDocument(); + }); + + it("renders cover for cover column", () => { + renderWithProviders( + , + ); + + // BookCover component should render (it will show a skeleton initially) + expect(screen.getByTestId("book-cover-skeleton")).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/OpenLibraryDialog.test.tsx b/src/__tests__/components/OpenLibraryDialog.test.tsx new file mode 100644 index 0000000..82c7d89 --- /dev/null +++ b/src/__tests__/components/OpenLibraryDialog.test.tsx @@ -0,0 +1,307 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { OpenLibraryDialog } from "@/components/OpenLibraryDialog"; +import { renderWithProviders } from "@/test-utils/testUtils"; + +jest.mock("@tauri-apps/plugin-dialog"); + +import * as dialogPlugin from "@tauri-apps/plugin-dialog"; + +describe("OpenLibraryDialog", () => { + const mockOnClose = jest.fn(); + const mockOnLibrarySelected = jest.fn().mockResolvedValue(undefined); + + beforeEach(() => { + localStorage.clear(); + jest.clearAllMocks(); + }); + + it("renders dialog when open is true", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Open Calibre Library")).toBeInTheDocument(); + expect( + screen.getByText(/Select the folder containing/), + ).toBeInTheDocument(); + }); + + it("does not render dialog when open is false", () => { + renderWithProviders( + , + ); + + expect(screen.queryByText("Open Calibre Library")).not.toBeInTheDocument(); + }); + + it("calls onClose when Cancel button is clicked", async () => { + renderWithProviders( + , + ); + + const cancelButton = screen.getByRole("button", { name: /Cancel/i }); + await userEvent.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("opens file dialog when Browse button is clicked", async () => { + (dialogPlugin.open as jest.Mock).mockResolvedValue("/test/library/path"); + + renderWithProviders( + , + ); + + const browseButton = screen.getByRole("button", { name: /Browse/i }); + await userEvent.click(browseButton); + + await waitFor(() => { + expect(dialogPlugin.open).toHaveBeenCalled(); + }); + }); + + it("sets selected path when directory is selected", async () => { + (dialogPlugin.open as jest.Mock).mockResolvedValue("/test/library/path"); + + renderWithProviders( + , + ); + + const browseButton = screen.getByRole("button", { name: /Browse/i }); + await userEvent.click(browseButton); + + await waitFor(() => { + const pathInput = screen.getByDisplayValue("/test/library/path"); + expect(pathInput).toBeInTheDocument(); + }); + }); + + it("disables Open Library button when no path is selected", () => { + renderWithProviders( + , + ); + + const openButton = screen.getByRole("button", { name: /Open Library/i }); + expect(openButton).toBeDisabled(); + }); + + it("enables Open Library button when path is selected", async () => { + (dialogPlugin.open as jest.Mock).mockResolvedValue("/test/library/path"); + + renderWithProviders( + , + ); + + const browseButton = screen.getByRole("button", { name: /Browse/i }); + await userEvent.click(browseButton); + + await waitFor(() => { + const openButton = screen.getByRole("button", { name: /Open Library/i }); + expect(openButton).not.toBeDisabled(); + }); + }); + + it("calls onLibrarySelected with selected path", async () => { + (dialogPlugin.open as jest.Mock).mockResolvedValue("/test/library/path"); + + renderWithProviders( + , + ); + + const browseButton = screen.getByRole("button", { name: /Browse/i }); + await userEvent.click(browseButton); + + await waitFor(() => { + expect( + screen.getByDisplayValue("/test/library/path"), + ).toBeInTheDocument(); + }); + + const openButton = screen.getByRole("button", { name: /Open Library/i }); + await userEvent.click(openButton); + + await waitFor(() => { + expect(mockOnLibrarySelected).toHaveBeenCalledWith("/test/library/path"); + }); + }); + + it("displays error when file dialog fails", async () => { + (dialogPlugin.open as jest.Mock).mockRejectedValue( + new Error("Dialog failed"), + ); + + renderWithProviders( + , + ); + + const browseButton = screen.getByRole("button", { name: /Browse/i }); + await userEvent.click(browseButton); + + await waitFor(() => { + expect(screen.getByText(/Dialog failed/)).toBeInTheDocument(); + }); + }); + + it("displays error when onLibrarySelected fails", async () => { + (dialogPlugin.open as jest.Mock).mockResolvedValue("/test/library/path"); + const mockOnLibrarySelectedError = jest + .fn() + .mockRejectedValue(new Error("Library open failed")); + + renderWithProviders( + , + ); + + const browseButton = screen.getByRole("button", { name: /Browse/i }); + await userEvent.click(browseButton); + + await waitFor(() => { + expect( + screen.getByDisplayValue("/test/library/path"), + ).toBeInTheDocument(); + }); + + const openButton = screen.getByRole("button", { name: /Open Library/i }); + await userEvent.click(openButton); + + await waitFor(() => { + expect(screen.getByText(/Library open failed/)).toBeInTheDocument(); + }); + }); + + it("shows error when no path is provided and Open Library is clicked", async () => { + renderWithProviders( + , + ); + + const openButton = screen.getByRole("button", { name: /Open Library/i }); + expect(openButton).toBeDisabled(); + }); + + it("clears selected path when dialog closes", async () => { + (dialogPlugin.open as jest.Mock).mockResolvedValue("/test/library/path"); + + const { rerender } = renderWithProviders( + , + ); + + const browseButton = screen.getByRole("button", { name: /Browse/i }); + await userEvent.click(browseButton); + + await waitFor(() => { + expect( + screen.getByDisplayValue("/test/library/path"), + ).toBeInTheDocument(); + }); + + rerender( + , + ); + + rerender( + , + ); + + const pathInput = screen.queryByDisplayValue("/test/library/path"); + expect(pathInput).not.toBeInTheDocument(); + }); + + it("sets loading state when isLoading is true", () => { + renderWithProviders( + , + ); + + const openButton = screen.getByRole("button", { name: /Open Library/i }); + expect(openButton).toBeDisabled(); + }); + + it("calls onClose after successful library selection", async () => { + (dialogPlugin.open as jest.Mock).mockResolvedValue("/test/library/path"); + + renderWithProviders( + , + ); + + const browseButton = screen.getByRole("button", { name: /Browse/i }); + await userEvent.click(browseButton); + + await waitFor(() => { + expect( + screen.getByDisplayValue("/test/library/path"), + ).toBeInTheDocument(); + }); + + const openButton = screen.getByRole("button", { name: /Open Library/i }); + await userEvent.click(openButton); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/__tests__/hooks/useLibrary.test.tsx b/src/__tests__/hooks/useLibrary.test.tsx new file mode 100644 index 0000000..138cdc2 --- /dev/null +++ b/src/__tests__/hooks/useLibrary.test.tsx @@ -0,0 +1,383 @@ +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useLibrary } from "@/hooks/useLibrary"; +import * as libraryApi from "@/api/library"; +import React from "react"; +import { BookCoverProvider } from "@/contexts/BookCoverContext"; + +jest.mock("@/api/library"); + +const mockBooks = [ + { + id: 1, + title: "Test Book 1", + pubdate: "2024-01-01T00:00:00Z", + isbn: "123-456", + authors: [{ id: 1, name: "Test Author", sort: "Author, Test" }], + publishers: ["Test Publisher"], + tags: [], + series: null, + rating: null, + formats: [], + languages: [], + }, + { + id: 2, + title: "Test Book 2", + pubdate: "2024-01-02T00:00:00Z", + isbn: "789-012", + authors: [], + publishers: [], + tags: [], + series: null, + rating: null, + formats: [], + languages: [], + }, +]; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe("useLibrary", () => { + beforeEach(() => { + localStorage.clear(); + jest.clearAllMocks(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("returns initial empty state", () => { + (libraryApi.libraryOpen as jest.Mock).mockResolvedValue(undefined); + (libraryApi.libraryGetAllBooks as jest.Mock).mockResolvedValue([]); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + expect(result.current.books).toEqual([]); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.libraryPath).toBeNull(); + expect(result.current.isLibraryOpen).toBe(false); + }); + + it("opens library with URI", async () => { + (libraryApi.libraryOpen as jest.Mock).mockResolvedValue(undefined); + (libraryApi.libraryGetAllBooks as jest.Mock).mockResolvedValue([]); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + await act(async () => { + await result.current.openLibrary("/test/path"); + }); + + expect(libraryApi.libraryOpen).toHaveBeenCalledWith("calibre:///test/path"); + expect(result.current.libraryPath).toBe("/test/path"); + expect(result.current.isLibraryOpen).toBe(true); + }); + + it("saves library path to localStorage when opening", async () => { + (libraryApi.libraryOpen as jest.Mock).mockResolvedValue(undefined); + (libraryApi.libraryGetAllBooks as jest.Mock).mockResolvedValue([]); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + await act(async () => { + await result.current.openLibrary("/test/path"); + }); + + const saved = localStorage.getItem("kikou_library_path"); + expect(saved).toBe("/test/path"); + }); + + it("handles error when opening library fails", async () => { + const errorMessage = "Failed to open library"; + (libraryApi.libraryOpen as jest.Mock).mockRejectedValueOnce( + new Error(errorMessage), + ); + (libraryApi.libraryGetAllBooks as jest.Mock).mockResolvedValue([]); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + await act(async () => { + await result.current.openLibrary("/invalid/path"); + }); + + expect(result.current.error).toBe(errorMessage); + expect(result.current.isLibraryOpen).toBe(false); + }); + + it("loads books from open library", async () => { + (libraryApi.libraryOpen as jest.Mock).mockResolvedValue(undefined); + (libraryApi.libraryGetAllBooks as jest.Mock).mockResolvedValue(mockBooks); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + await act(async () => { + await result.current.openLibrary("/test/path"); + }); + + await act(async () => { + await result.current.loadBooks(); + }); + + expect(result.current.books).toEqual(mockBooks); + }); + + it("sets loading state while fetching books", async () => { + (libraryApi.libraryOpen as jest.Mock).mockResolvedValue(undefined); + (libraryApi.libraryGetAllBooks as jest.Mock).mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(mockBooks), 50)), + ); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + await act(async () => { + await result.current.openLibrary("/test/path"); + }); + + await act(async () => { + await result.current.loadBooks(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.books.length).toBeGreaterThan(0); + }); + + it("handles error when loading books fails", async () => { + const errorMessage = "Failed to load books"; + (libraryApi.libraryOpen as jest.Mock).mockResolvedValue(undefined); + (libraryApi.libraryGetAllBooks as jest.Mock).mockRejectedValue( + new Error(errorMessage), + ); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + await act(async () => { + await result.current.openLibrary("/test/path"); + }); + + await act(async () => { + await result.current.loadBooks(); + }); + + expect(result.current.error).toBe(errorMessage); + expect(result.current.books).toEqual([]); + }); + + it("prevents loading books without open library", async () => { + // Explicitly clear localStorage and mocks to prevent auto-load from previous tests + localStorage.clear(); + jest.clearAllMocks(); + + (libraryApi.libraryOpen as jest.Mock).mockResolvedValue(undefined); + (libraryApi.libraryGetAllBooks as jest.Mock).mockResolvedValue(mockBooks); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + // Clear the mock after rendering to account for any auto-load effects + (libraryApi.libraryGetAllBooks as jest.Mock).mockClear(); + + await act(async () => { + await result.current.loadBooks(); + }); + + expect(result.current.error).toBe("No library opened"); + // The API should NOT be called when there is no library open + expect(libraryApi.libraryGetAllBooks).not.toHaveBeenCalled(); + }); + + it("loads library path from localStorage on component mount", async () => { + (libraryApi.libraryOpen as jest.Mock).mockResolvedValue(undefined); + (libraryApi.libraryGetAllBooks as jest.Mock).mockResolvedValue([]); + localStorage.setItem("kikou_library_path", "/saved/path"); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + await waitFor(() => { + expect(result.current.libraryPath).toBe("/saved/path"); + }); + }); + + it("clears error when successfully opening library", async () => { + (libraryApi.libraryOpen as jest.Mock) + .mockRejectedValueOnce(new Error("First attempt failed")) + .mockResolvedValueOnce(undefined); + (libraryApi.libraryGetAllBooks as jest.Mock).mockResolvedValue([]); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + await act(async () => { + await result.current.openLibrary("/path1"); + }); + + expect(result.current.error).toBeTruthy(); + + await act(async () => { + await result.current.openLibrary("/path2"); + }); + + expect(result.current.error).toBeNull(); + }); + + it("handles string error objects", async () => { + (libraryApi.libraryOpen as jest.Mock).mockRejectedValueOnce( + "String error message", + ); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + await act(async () => { + await result.current.openLibrary("/test/path"); + }); + + expect(result.current.error).toBe("Failed to open library"); + }); + + it("can manually load books after opening library", async () => { + (libraryApi.libraryOpen as jest.Mock).mockResolvedValue(undefined); + (libraryApi.libraryGetAllBooks as jest.Mock).mockResolvedValue(mockBooks); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + await act(async () => { + await result.current.openLibrary("/test/path"); + }); + + await waitFor(() => { + expect(result.current.isLibraryOpen).toBe(true); + }); + + // Reset the mock to count only the manual load call + (libraryApi.libraryGetAllBooks as jest.Mock).mockClear(); + + await act(async () => { + await result.current.loadBooks(); + }); + + expect(libraryApi.libraryGetAllBooks).toHaveBeenCalledTimes(1); + }); + + describe("Bug fix: Backend calls should be made when opening library with known path", () => { + it("should call libraryOpen and libraryGetAllBooks when opening a library", async () => { + (libraryApi.libraryOpen as jest.Mock).mockResolvedValue(undefined); + (libraryApi.libraryGetAllBooks as jest.Mock).mockResolvedValue(mockBooks); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + await act(async () => { + await result.current.openLibrary("/test/path"); + }); + + // Both API calls should be made + expect(libraryApi.libraryOpen).toHaveBeenCalledWith( + "calibre:///test/path", + ); + + // Manually load books to verify the call happens + await act(async () => { + await result.current.loadBooks(); + }); + + expect(libraryApi.libraryGetAllBooks).toHaveBeenCalled(); + }); + + it("should make backend calls when library path is set via savedPath then used", async () => { + (libraryApi.libraryOpen as jest.Mock).mockResolvedValue(undefined); + (libraryApi.libraryGetAllBooks as jest.Mock).mockResolvedValue(mockBooks); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + // Simulate opening with an explicit call (not auto-load) + await act(async () => { + await result.current.openLibrary("/saved/path"); + }); + + // Verify the backend was called + expect(libraryApi.libraryOpen).toHaveBeenCalledWith( + "calibre:///saved/path", + ); + + // Load books + await act(async () => { + await result.current.loadBooks(); + }); + + expect(libraryApi.libraryGetAllBooks).toHaveBeenCalled(); + expect(result.current.books).toEqual(mockBooks); + }); + + it("should auto-load books after opening library with saved path", async () => { + (libraryApi.libraryOpen as jest.Mock).mockResolvedValue(undefined); + (libraryApi.libraryGetAllBooks as jest.Mock).mockResolvedValue(mockBooks); + + // Set saved path before rendering hook + localStorage.setItem("kikou_library_path", "/saved/path"); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + // Wait for path to be loaded from localStorage + await waitFor(() => { + expect(result.current.libraryPath).toBe("/saved/path"); + }); + + // Wait for auto-load to happen + await waitFor(() => { + expect(libraryApi.libraryOpen).toHaveBeenCalledWith( + "calibre:///saved/path", + ); + }); + + // Wait for books to be auto-loaded + await waitFor(() => { + expect(libraryApi.libraryGetAllBooks).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(result.current.books).toEqual(mockBooks); + }); + }); + + it("should not call backend multiple times when opening same library", async () => { + (libraryApi.libraryOpen as jest.Mock).mockResolvedValue(undefined); + (libraryApi.libraryGetAllBooks as jest.Mock).mockResolvedValue(mockBooks); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + await act(async () => { + await result.current.openLibrary("/test/path"); + }); + + const openCallCount = (libraryApi.libraryOpen as jest.Mock).mock.calls + .length; + + // Try opening the same path again + await act(async () => { + await result.current.openLibrary("/test/path"); + }); + + // Should have called open twice (once for each call) + expect((libraryApi.libraryOpen as jest.Mock).mock.calls.length).toBe( + openCallCount + 1, + ); + }); + + it("should properly handle isLoading state during library operations", async () => { + (libraryApi.libraryOpen as jest.Mock).mockResolvedValue(undefined); + (libraryApi.libraryGetAllBooks as jest.Mock).mockResolvedValue(mockBooks); + + const { result } = renderHook(() => useLibrary(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + + await act(async () => { + await result.current.openLibrary("/test/path"); + }); + + // After opening, isLoading should be false and library should be open + expect(result.current.isLoading).toBe(false); + expect(result.current.isLibraryOpen).toBe(true); + }); + }); +}); diff --git a/src/__tests__/hooks/useTableColumns.test.ts b/src/__tests__/hooks/useTableColumns.test.ts new file mode 100644 index 0000000..22fd578 --- /dev/null +++ b/src/__tests__/hooks/useTableColumns.test.ts @@ -0,0 +1,178 @@ +import { renderHook, act } from "@testing-library/react"; +import { useTableColumns } from "@/hooks/useTableColumns"; + +describe("useTableColumns", () => { + beforeEach(() => { + localStorage.clear(); + jest.clearAllMocks(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("returns default columns on first render", () => { + const { result } = renderHook(() => useTableColumns()); + + expect(result.current.columns).toHaveLength(11); + expect(result.current.visibleColumns.length).toBeGreaterThan(0); + }); + + it("loads columns from localStorage if they exist", () => { + const customColumns = [ + { id: "title", label: "Title", visible: false, order: 0 }, + { id: "authors", label: "Authors", visible: true, order: 1 }, + ]; + + localStorage.setItem("kikou_table_columns", JSON.stringify(customColumns)); + + const { result } = renderHook(() => useTableColumns()); + + expect(result.current.columns).toEqual(customColumns); + }); + + it("toggles column visibility", () => { + const { result } = renderHook(() => useTableColumns()); + + const titleColumn = result.current.columns.find((c) => c.id === "title"); + const initialVisibility = titleColumn?.visible; + + act(() => { + result.current.toggleColumnVisibility("title"); + }); + + const updatedColumn = result.current.columns.find((c) => c.id === "title"); + expect(updatedColumn?.visible).toBe(!initialVisibility); + }); + + it("saves columns to localStorage when toggling visibility", () => { + const { result } = renderHook(() => useTableColumns()); + + act(() => { + result.current.toggleColumnVisibility("isbn"); + }); + + const saved = localStorage.getItem("kikou_table_columns"); + expect(saved).toBeTruthy(); + + const parsedColumns = JSON.parse(saved!); + const isbnColumn = parsedColumns.find((c: any) => c.id === "isbn"); + expect(isbnColumn?.visible).toBe(true); + }); + + it("filters visible columns correctly", () => { + const { result } = renderHook(() => useTableColumns()); + + act(() => { + result.current.toggleColumnVisibility("isbn"); + result.current.toggleColumnVisibility("languages"); + }); + + const visibleIds = result.current.visibleColumns.map((c) => c.id); + expect(visibleIds).toContain("isbn"); + expect(visibleIds).toContain("languages"); + expect(visibleIds).not.toContain("formats"); + }); + + it("reorders columns", () => { + const { result } = renderHook(() => useTableColumns()); + + const initialOrder = result.current.columns.map((c) => c.id); + + act(() => { + result.current.reorderColumns("isbn", 0); + }); + + const newOrder = result.current.columns.map((c) => c.id); + expect(newOrder[0]).toBe("isbn"); + expect(newOrder).not.toEqual(initialOrder); + }); + + it("recalculates order numbers after reordering", () => { + const { result } = renderHook(() => useTableColumns()); + + act(() => { + result.current.reorderColumns("title", 5); + }); + + const orders = result.current.columns.map((c) => c.order); + const expectedOrders = Array.from({ length: orders.length }, (_, i) => i); + expect(orders).toEqual(expectedOrders); + }); + + it("persists reordered columns to localStorage", () => { + const { result } = renderHook(() => useTableColumns()); + + act(() => { + result.current.reorderColumns("isbn", 0); + }); + + const saved = localStorage.getItem("kikou_table_columns"); + const parsedColumns = JSON.parse(saved!); + expect(parsedColumns[0].id).toBe("isbn"); + }); + + it("resets columns to defaults", () => { + const { result } = renderHook(() => useTableColumns()); + + act(() => { + result.current.toggleColumnVisibility("title"); + result.current.toggleColumnVisibility("isbn"); + result.current.reorderColumns("authors", 0); + }); + + act(() => { + result.current.resetColumns(); + }); + + // After reset, columns should be back to defaults + expect(result.current.columns[0].id).toBe("cover"); + expect(result.current.columns[0].order).toBe(0); + + // Title should be visible again (default state) + const titleColumn = result.current.columns.find((c) => c.id === "title"); + expect(titleColumn?.visible).toBe(true); + expect(titleColumn?.order).toBe(1); + + // ISBN should be hidden again (default state) + const isbnColumn = result.current.columns.find((c) => c.id === "isbn"); + expect(isbnColumn?.visible).toBe(false); + expect(isbnColumn?.order).toBe(7); + }); + + it("handles invalid localStorage data gracefully", () => { + localStorage.setItem("kikou_table_columns", "invalid json"); + + const { result } = renderHook(() => useTableColumns()); + + expect(result.current.columns.length).toBeGreaterThan(0); + expect(result.current.columns[0].id).toBe("cover"); + }); + + it("maintains visible column order", () => { + const { result } = renderHook(() => useTableColumns()); + + act(() => { + result.current.toggleColumnVisibility("isbn"); + result.current.toggleColumnVisibility("languages"); + }); + + const visibleOrders = result.current.visibleColumns.map((c) => c.order); + const isSorted = visibleOrders.every( + (order, i) => i === 0 || order > visibleOrders[i - 1], + ); + expect(isSorted).toBe(true); + }); + + it("does not modify column if reordering with invalid id", () => { + const { result } = renderHook(() => useTableColumns()); + + const initialColumns = [...result.current.columns]; + + act(() => { + result.current.reorderColumns("nonexistent", 0); + }); + + expect(result.current.columns).toEqual(initialColumns); + }); +}); diff --git a/src/api/library.ts b/src/api/library.ts new file mode 100644 index 0000000..8fad7b4 --- /dev/null +++ b/src/api/library.ts @@ -0,0 +1,41 @@ +import { invoke, Channel } from "@tauri-apps/api/core"; +import { Book } from "@/types/book"; + +export async function libraryOpen(uri: string): Promise { + return await invoke("library_open", { uri }); +} + +export async function libraryGetAllBooks(): Promise { + return await invoke("library_get_all_books"); +} + +export async function libraryGetBook(bookId: number): Promise { + return await invoke("library_get_book", { book_id: bookId }); +} + +export async function libraryGetBookCount(): Promise { + return await invoke("library_get_book_count"); +} + +export interface CoverStreamEvent { + event: "started" | "cover" | "error" | "finished"; + data: + | { total_books: number } + | { book_id: number; data_base64: string } + | { book_id: number; message: string } + | null; +} + +export async function streamBookCovers( + bookIds: number[], + onEvent: (event: CoverStreamEvent) => void, +): Promise { + const channel = new Channel(); + + channel.onmessage = onEvent; + + await invoke("library_stream_book_covers", { + bookIds, + onEvent: channel, + }); +} diff --git a/src/components/BookCover.tsx b/src/components/BookCover.tsx new file mode 100644 index 0000000..f0ae1ca --- /dev/null +++ b/src/components/BookCover.tsx @@ -0,0 +1,87 @@ +import { Box, Skeleton, Typography } from "@mui/joy"; +import { useBookCover } from "@/hooks/useBookCover"; + +export interface BookCoverProps { + bookId: number | null; + width?: number | string; + height?: number | string; + alt?: string; +} + +export function BookCover({ + bookId, + width = 60, + height = 90, + alt = "Book cover", +}: BookCoverProps): React.ReactElement { + const { coverUrl, loading, error } = useBookCover(bookId); + + if (bookId === null || error || (!loading && !coverUrl)) { + return ( + + + No cover + + + ); + } + + if (loading) { + return ( + + + + ); + } + + return ( + + + + ); +} diff --git a/src/components/BooksTable.tsx b/src/components/BooksTable.tsx new file mode 100644 index 0000000..8e7ff7f --- /dev/null +++ b/src/components/BooksTable.tsx @@ -0,0 +1,547 @@ +import { useState, useMemo, useCallback, useEffect } from "react"; +import { + Box, + Input, + Button, + Stack, + Typography, + Menu, + MenuItem, + Checkbox, + Alert, + Sheet, + Divider, +} from "@mui/joy"; +import { Book } from "@/types/book"; +import { BookCover } from "./BookCover"; +import { ColumnConfig } from "@/hooks/useTableColumns"; +import { devLog } from "@/utils/devLog"; + +export interface BooksTableProps { + books: Book[]; + columns: ColumnConfig[]; + onToggleColumnVisibility: (columnId: string) => void; + onReorderColumns: (columnId: string, newOrder: number) => void; + onResetColumns: () => void; + isLoading?: boolean; + error?: string | null; +} + +type SortDirection = "asc" | "desc" | null; + +const SEARCHABLE_COLUMNS = ["title", "authors", "publisher", "tags"]; + +function normalizeText(text: string): string { + return text.toLowerCase().trim(); +} + +function getBookFieldValue( + book: Book, + fieldId: string, +): string | string[] | number | boolean | null { + switch (fieldId) { + case "title": + return book.title; + case "authors": + return book.authors.map((a) => a.name); + case "series": + return book.series?.name || ""; + case "publisher": + return book.publishers[0] || ""; + case "pubdate": + return book.pubdate; + case "tags": + return book.tags.map((t) => t.name); + case "isbn": + return book.isbn; + case "languages": + return book.languages; + case "formats": + return book.formats; + case "rating": + return book.rating; + default: + return ""; + } +} + +function formatCellValue( + value: string | string[] | number | boolean | null, +): string { + if (value === null || value === undefined) { + return ""; + } + + if (Array.isArray(value)) { + return value.join(", "); + } + + if (typeof value === "boolean") { + return value ? "Yes" : "No"; + } + + return String(value); +} + +function parseSearchQuery(query: string): Record { + const result: Record = { _text: [] }; + const advancedPattern = /(\w+):(?:~?"([^"]+)"|([^\s]+))/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = advancedPattern.exec(query)) !== null) { + // Add text before this match to free text search + if (match.index > lastIndex) { + const textBefore = query.substring(lastIndex, match.index).trim(); + + if (textBefore) { + result._text.push(textBefore); + } + } + + const field = match[1]; + const value = match[2] || match[3]; + + if (!result[field]) { + result[field] = []; + } + + result[field].push(value); + + lastIndex = advancedPattern.lastIndex; + } + + // Add remaining text + if (lastIndex < query.length) { + const remainingText = query.substring(lastIndex).trim(); + + if (remainingText) { + result._text.push(remainingText); + } + } + + return result; +} + +function matchesSearch(book: Book, searchQuery: string): boolean { + if (!searchQuery.trim()) { + return true; + } + + const parsedQuery = parseSearchQuery(normalizeText(searchQuery)); + + // Check free text search + if (parsedQuery._text.length > 0) { + const freeTextMatches = parsedQuery._text.every((term) => { + return SEARCHABLE_COLUMNS.some((col) => { + const value = getBookFieldValue(book, col); + const normalizedValue = normalizeText(formatCellValue(value)); + + return normalizedValue.includes(term); + }); + }); + + if (!freeTextMatches) { + return false; + } + } + + // Check field-specific searches + for (const [field, terms] of Object.entries(parsedQuery)) { + if (field === "_text") { + continue; + } + + const fieldValue = normalizeText( + formatCellValue(getBookFieldValue(book, field)), + ); + const allTermsMatch = terms.every((term) => fieldValue.includes(term)); + + if (!allTermsMatch) { + return false; + } + } + + return true; +} + +export function BooksTable({ + books, + columns, + onToggleColumnVisibility, + onResetColumns, + isLoading = false, + error = null, +}: BooksTableProps): React.ReactElement { + const [searchQuery, setSearchQuery] = useState(""); + const [columnMenuAnchor, setColumnMenuAnchor] = useState( + null, + ); + const [columnWidths, setColumnWidths] = useState>({}); + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState(null); + + const visibleColumns = columns.filter((col) => col.visible); + + useEffect(() => { + if (!columnMenuAnchor) return; + + const handleClickOutside = (event: MouseEvent): void => { + const target = event.target as HTMLElement; + + if ( + columnMenuAnchor.contains(target) || + target.closest('[role="menu"]') + ) { + return; + } + + handleColumnMenuClose(); + }; + + const timeoutId = setTimeout(() => { + document.addEventListener("mousedown", handleClickOutside); + }, 100); + + return () => { + clearTimeout(timeoutId); + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [columnMenuAnchor]); + + const filteredBooks = useMemo( + (): Book[] => books.filter((book) => matchesSearch(book, searchQuery)), + [books, searchQuery], + ); + + const sortedBooks = useMemo(() => { + if (!sortColumn || !sortDirection) { + return filteredBooks; + } + + return [...filteredBooks].sort((a, b) => { + const aValue = getBookFieldValue(a, sortColumn); + const bValue = getBookFieldValue(b, sortColumn); + + const aStr = formatCellValue(aValue).toLowerCase(); + const bStr = formatCellValue(bValue).toLowerCase(); + + if (aStr < bStr) return sortDirection === "asc" ? -1 : 1; + + if (aStr > bStr) return sortDirection === "asc" ? 1 : -1; + + return 0; + }); + }, [filteredBooks, sortColumn, sortDirection]); + + const handleColumnMenuOpen = ( + event: React.MouseEvent, + ): void => { + setColumnMenuAnchor(event.currentTarget); + }; + + const handleColumnMenuClose = (): void => { + setColumnMenuAnchor(null); + }; + + const handleSort = (columnId: string): void => { + if (columnId === "cover") return; + + if (sortColumn === columnId) { + if (sortDirection === "asc") { + setSortDirection("desc"); + } else if (sortDirection === "desc") { + setSortDirection(null); + setSortColumn(null); + } + } else { + setSortColumn(columnId); + setSortDirection("asc"); + } + }; + + const handleMouseDown = useCallback( + (columnId: string, e: React.MouseEvent): void => { + devLog("Mouse down on column:", columnId); + + e.preventDefault(); + + const startXPos = e.clientX; + const startColWidth = columnWidths[columnId] ?? 100; + + const handleMouseMove = (moveEvent: MouseEvent): void => { + const diff = moveEvent.clientX - startXPos; + const newWidth = Math.max(10, startColWidth + diff); + + devLog( + "Resizing column:", + columnId, + "diff:", + diff, + "New width:", + newWidth, + ); + + setColumnWidths((prev) => ({ + ...prev, + [columnId]: newWidth, + })); + }; + + const handleMouseUp = (): void => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }, + [columnWidths], + ); + + const getColumnWidth = (columnId: string): number => { + if (columnId === "cover") { + return 100; + } + + return columnWidths[columnId] ?? 300; + }; + + const getTotalTableWidth = (): number => { + return visibleColumns.reduce( + (total, col) => total + getColumnWidth(col.id), + 0, + ); + }; + + const renderCellContent = (book: Book, columnId: string): React.ReactNode => { + if (columnId === "cover") { + return ; + } + + const value = getBookFieldValue(book, columnId); + + return formatCellValue(value); + }; + + if (error) { + return ( + + Error: {error} + + ); + } + + if (isLoading) { + return ( + + Loading books... + + ); + } + + return ( + + {/* Search Bar */} + + setSearchQuery(e.target.value)} + sx={{ flexGrow: 1 }} + /> + + + + {/* Column Menu */} + + {columns.map((col) => ( + onToggleColumnVisibility(col.id)} + > + + onToggleColumnVisibility(col.id)} + onClick={(e) => e.stopPropagation()} + /> + + {col.label} + + + ))} + + + + onResetColumns()} + > + Reset to Default + + + + {/* Table */} + {filteredBooks.length === 0 ? ( + + No books found + + ) : ( + + *:first-of-type": { + position: "sticky", + left: 0, + zIndex: 1, + backgroundColor: "var(--joy-palette-background-surface)", + }, + "& thead tr > *:first-of-type": { + backgroundColor: "var(--joy-palette-background-level1)", + zIndex: 4, + }, + }} + > + + + {visibleColumns.map((col) => ( + handleSort(col.id)} + > + + {col.label} + {col.id !== "cover" && sortColumn === col.id && ( + {sortDirection === "asc" ? "↑" : "↓"} + )} + + {col.id !== "cover" && ( + handleMouseDown(col.id, e)} + /> + )} + + ))} + + + + {sortedBooks.map((book) => ( + + {visibleColumns.map((col) => ( + + {col.id === "cover" ? ( + + {renderCellContent(book, col.id)} + + ) : ( + renderCellContent(book, col.id) + )} + + ))} + + ))} + + + + )} + + {/* Results Info */} + + Showing {filteredBooks.length} of {books.length} books + + + ); +} diff --git a/src/components/OpenLibraryDialog.tsx b/src/components/OpenLibraryDialog.tsx new file mode 100644 index 0000000..46eef83 --- /dev/null +++ b/src/components/OpenLibraryDialog.tsx @@ -0,0 +1,157 @@ +import { useState } from "react"; +import { + Modal, + ModalClose, + Sheet, + Button, + FormControl, + FormLabel, + Input, + Alert, + Stack, + Typography, +} from "@mui/joy"; +import { open } from "@tauri-apps/plugin-dialog"; + +export interface OpenLibraryDialogProps { + open: boolean; + onClose: () => void; + onLibrarySelected: (path: string) => Promise; + isLoading?: boolean; +} + +export function OpenLibraryDialog({ + open: isOpen, + onClose, + onLibrarySelected, + isLoading = false, +}: OpenLibraryDialogProps) { + const [selectedPath, setSelectedPath] = useState(""); + const [error, setError] = useState(null); + + const handleBrowse = async () => { + try { + setError(null); + const selected = await open({ + directory: true, + title: "Select Calibre Library", + defaultPath: selectedPath || undefined, + }); + + if (selected) { + setSelectedPath(selected); + } + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to open file dialog"; + + setError(errorMessage); + } + }; + + const handleConfirm = async () => { + if (!selectedPath) { + setError("Please select a library path"); + return; + } + + try { + setError(null); + await onLibrarySelected(selectedPath); + setSelectedPath(""); + onClose(); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to open library"; + + setError(errorMessage); + } + }; + + const handleClose = () => { + setSelectedPath(""); + setError(null); + onClose(); + }; + + return ( + + + + + Open Calibre Library + + + + + Select the folder containing the Calibre library (must have + metadata.db) + + + {error && ( + + {error} + + )} + + + Library Path + setSelectedPath(e.target.value)} + placeholder="Click Browse to select a folder" + readOnly + /> + + + + + + + + + + + + ); +} diff --git a/src/components/PageWrapperFactory.tsx b/src/components/PageWrapperFactory.tsx index b008b5f..5095bbf 100644 --- a/src/components/PageWrapperFactory.tsx +++ b/src/components/PageWrapperFactory.tsx @@ -3,6 +3,7 @@ import { Box } from "@mui/joy"; import { NavigationProvider } from "@/contexts/NavigationContext"; import NavigationDialogManager from "@/components/ui/NavigationDialogManager"; import { ArchiveProvider } from "@/contexts/ArchiveContext"; +import { BookCoverProvider } from "@/contexts/BookCoverContext"; import ArchiveValidator from "@/components/file/ArchiveValidator"; import Navigation from "./ui/Navigation"; @@ -40,18 +41,20 @@ export function getPageWrapper({ return ( - - - - {wrappedContent} - + + + + + {wrappedContent} + + ); } diff --git a/src/components/README.md b/src/components/README.md new file mode 100644 index 0000000..85c55a9 --- /dev/null +++ b/src/components/README.md @@ -0,0 +1,341 @@ +# Library UI Components + +This directory contains reusable React components for the Calibre library management interface. + +## Components + +### BooksTable + +Displays a searchable, filterable table of books with customizable columns. + +**Features:** + +- Dynamic column visibility and reordering +- Advanced search with field-specific queries (e.g., `title:"search term"`) +- Free-text search across multiple columns +- Sticky first column for horizontal scrolling +- Book count display +- Empty state handling +- Error display +- Loading state + +**Props:** + +```typescript +interface BooksTableProps { + books: Book[]; + columns: ColumnConfig[]; + onToggleColumnVisibility: (columnId: string) => void; + onReorderColumns: (columnId: string, newOrder: number) => void; + onResetColumns: () => void; + isLoading?: boolean; + error?: string | null; +} +``` + +**Search Syntax:** + +- Free text: `adventure` - searches across all text columns +- Field specific: `title:"The Hobbit"` - searches only title field +- Multiple terms: `title:"Hobbit" author:"Tolkien"` - must match all terms +- Case insensitive and trimmed automatically + +**Example:** + +```tsx +import { BooksTable } from "@/components/BooksTable"; + +; +``` + +### BookCoverSkeleton + +Displays a loading skeleton for book covers. + +**Features:** + +- Wave animation +- Customizable width and height +- Rounded corners +- Centered display + +**Props:** + +```typescript +interface BookCoverSkeletonProps { + width?: number | string; + height?: number | string; +} +``` + +**Example:** + +```tsx +import { BookCoverSkeleton } from "@/components/BookCoverSkeleton"; + +; +``` + +### OpenLibraryDialog + +Modal dialog for selecting and opening a Calibre library. + +**Features:** + +- File browser integration using Tauri +- Path validation +- Error handling and display +- Loading state +- Cancel and confirm actions +- Info alert about requirements + +**Props:** + +```typescript +interface OpenLibraryDialogProps { + open: boolean; + onClose: () => void; + onLibrarySelected: (path: string) => Promise; + isLoading?: boolean; +} +``` + +**Example:** + +```tsx +import { OpenLibraryDialog } from "@/components/OpenLibraryDialog"; + +const [dialogOpen, setDialogOpen] = useState(false); + + setDialogOpen(false)} + onLibrarySelected={handleLibrarySelected} + isLoading={false} +/>; +``` + +## Hooks + +### useLibrary + +Manages library state and operations including opening libraries and loading books. + +**Returns:** + +```typescript +interface UseLibraryReturn { + books: Book[]; + isLoading: boolean; + error: string | null; + libraryPath: string | null; + openLibrary: (path: string) => Promise; + loadBooks: () => Promise; + isLibraryOpen: boolean; +} +``` + +**Features:** + +- Automatic localStorage persistence +- Auto-loads saved library on mount +- Auto-loads books after opening library +- Error handling and recovery +- Loading state management + +**Example:** + +```tsx +import { useLibrary } from "@/hooks/useLibrary"; + +const { + books, + isLoading, + error, + libraryPath, + openLibrary, + loadBooks, + isLibraryOpen, +} = useLibrary(); +``` + +### useTableColumns + +Manages table column visibility and ordering with localStorage persistence. + +**Returns:** + +```typescript +interface UseTableColumnsReturn { + columns: ColumnConfig[]; + visibleColumns: ColumnConfig[]; + toggleColumnVisibility: (columnId: string) => void; + reorderColumns: (columnId: string, newOrder: number) => void; + resetColumns: () => void; +} +``` + +**Features:** + +- localStorage persistence +- Default column configuration +- Toggle visibility +- Reorder columns +- Reset to defaults +- Filter visible columns + +**Default Columns:** + +- cover (visible) +- title (visible) +- authors (visible) +- series (visible) +- publisher (visible) +- pubdate (visible) +- tags (visible) +- isbn (hidden) +- languages (hidden) +- formats (hidden) +- rating (hidden) + +**Example:** + +```tsx +import { useTableColumns } from "@/hooks/useTableColumns"; + +const { + columns, + visibleColumns, + toggleColumnVisibility, + reorderColumns, + resetColumns, +} = useTableColumns(); +``` + +## Data Types + +### Book + +```typescript +interface Book { + id: number; + title: string; + sort: string; + timestamp: string; + pubdate: string; + series_index: number; + author_sort: string; + isbn: string; + lccn: string; + path: string; + has_cover: boolean; + authors: Author[]; + publishers: string[]; + tags: Tag[]; + series: Series | null; + comments: string | null; + rating: number | null; + formats: string[]; + identifiers: Identifier[]; + languages: string[]; +} +``` + +### ColumnConfig + +```typescript +interface ColumnConfig { + id: string; + label: string; + visible: boolean; + order: number; +} +``` + +## Usage Example + +Complete example of using all components together: + +```tsx +import { useLibrary } from "@/hooks/useLibrary"; +import { useTableColumns } from "@/hooks/useTableColumns"; +import { BooksTable } from "@/components/BooksTable"; +import { OpenLibraryDialog } from "@/components/OpenLibraryDialog"; +import { useState } from "react"; + +export function LibraryPage() { + const [dialogOpen, setDialogOpen] = useState(false); + + const { books, isLoading, error, libraryPath, openLibrary, isLibraryOpen } = + useLibrary(); + + const { columns, toggleColumnVisibility, reorderColumns, resetColumns } = + useTableColumns(); + + if (!isLibraryOpen) { + return ( + <> + + + setDialogOpen(false)} + onLibrarySelected={openLibrary} + /> + + ); + } + + return ( + <> +

Library: {libraryPath}

+ + + + ); +} +``` + +## Testing + +All components have comprehensive test coverage. Run tests with: + +```bash +pnpm test +``` + +Test files are located in `src/__tests__/`: + +- `components/BooksTable.test.tsx` - BooksTable component tests +- `components/OpenLibraryDialog.test.tsx` - OpenLibraryDialog component tests +- `hooks/useLibrary.test.ts` - useLibrary hook tests +- `hooks/useTableColumns.test.ts` - useTableColumns hook tests + +## localStorage Keys + +- `kikou_library_path` - Stores the currently open library path +- `kikou_table_columns` - Stores column visibility and order configuration + +## Notes + +- All components use Joy UI for consistent styling +- Components are fully typed with TypeScript +- Search is performed client-side for better responsiveness +- Book covers are currently placeholders with skeleton loaders +- Column ordering updates are persisted immediately to localStorage diff --git a/src/components/__tests__/BookCover.test.tsx b/src/components/__tests__/BookCover.test.tsx new file mode 100644 index 0000000..14ba07b --- /dev/null +++ b/src/components/__tests__/BookCover.test.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { render, screen, act } from "@testing-library/react"; +import { BookCover } from "../BookCover"; +import { BookCoverProvider } from "@/contexts/BookCoverContext"; + +const MockedBookCover = ({ + bookId, + ...props +}: React.ComponentProps) => { + return ( + + + + ); +}; + +describe("BookCover", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); + }); + + it("should render skeleton when loading", () => { + render(); + + const skeleton = document.querySelector(".MuiSkeleton-root"); + + expect(skeleton).toBeInTheDocument(); + }); + + it("should render 'No cover' when bookId is null", async () => { + render(); + + await screen.findByText("No cover"); + + expect(screen.getByText("No cover")).toBeInTheDocument(); + }); + + it("should render image alt text correctly", () => { + const ref = React.createRef>(); + + ref.current = { 1: "data:image/jpeg;base64,test123" }; + + render(); + + act(() => { + jest.advanceTimersByTime(200); + }); + + const img = screen.queryByAltText("Test Book Cover"); + + if (img) { + expect(img).toBeInTheDocument(); + } + }); + + it("should handle width and height props", async () => { + render(); + + await screen.findByText("No cover"); + + const container = screen.getByText("No cover").parentElement; + + expect(container).toHaveStyle({ width: "100px", height: "150px" }); + }); +}); diff --git a/src/components/__tests__/BooksTable.menu.test.tsx b/src/components/__tests__/BooksTable.menu.test.tsx new file mode 100644 index 0000000..14ab340 --- /dev/null +++ b/src/components/__tests__/BooksTable.menu.test.tsx @@ -0,0 +1,107 @@ +import { screen, fireEvent, waitFor } from "@testing-library/react"; +import { BooksTable } from "../BooksTable"; +import { Book } from "@/types/book"; +import { renderWithProviders } from "@/test-utils/testUtils"; + +const mockBooks: Book[] = [ + { + id: 1, + title: "Test Book", + pubdate: "2024-01-01T00:00:00Z", + isbn: "1234567890", + authors: [{ id: 1, name: "Test Author", sort: "Author, Test" }], + publishers: ["Test Publisher"], + tags: [{ id: 1, name: "Fiction" }], + series: null, + rating: 5, + formats: ["EPUB"], + languages: ["en"], + }, +]; + +const mockColumns = [ + { id: "cover", label: "Cover", visible: true, order: 0 }, + { id: "title", label: "Title", visible: true, order: 1 }, + { id: "authors", label: "Authors", visible: true, order: 2 }, +]; + +const mockReorderColumns = jest.fn(); + +describe("BooksTable - Column Menu", () => { + it("should open column menu when Columns button is clicked", () => { + const mockToggle = jest.fn(); + const mockReset = jest.fn(); + + renderWithProviders( + , + ); + + const columnsButton = screen.getByRole("button", { name: /columns/i }); + fireEvent.click(columnsButton); + + // Check if menu is visible by finding the menu and checking for column checkboxes + expect(screen.getByTestId("column-checkbox-cover")).toBeInTheDocument(); + expect(screen.getByTestId("column-checkbox-title")).toBeInTheDocument(); + expect(screen.getByTestId("column-checkbox-authors")).toBeInTheDocument(); + }); + + it("should call onToggleColumnVisibility when clicking on a column menu item", () => { + const mockToggle = jest.fn(); + const mockReset = jest.fn(); + + renderWithProviders( + , + ); + + // Open menu + const columnsButton = screen.getByRole("button", { name: /columns/i }); + fireEvent.click(columnsButton); + + // Find the title menu item and click it + const titleMenuItems = screen.getAllByRole("menuitem").filter((item) => { + return item.textContent?.includes("Title"); + }); + + if (titleMenuItems.length > 0) { + fireEvent.click(titleMenuItems[0]); + expect(mockToggle).toHaveBeenCalledWith("title"); + } + }); + + it("should call onResetColumns when clicking Reset button", () => { + const mockToggle = jest.fn(); + const mockReset = jest.fn(); + + renderWithProviders( + , + ); + + // Open menu + const columnsButton = screen.getByRole("button", { name: /columns/i }); + fireEvent.click(columnsButton); + + // Click reset button + const resetButton = screen.getByTestId("reset-columns-button"); + fireEvent.click(resetButton); + + expect(mockReset).toHaveBeenCalled(); + }); +}); diff --git a/src/contexts/BookCoverContext.tsx b/src/contexts/BookCoverContext.tsx new file mode 100644 index 0000000..aec09fd --- /dev/null +++ b/src/contexts/BookCoverContext.tsx @@ -0,0 +1,35 @@ +import React, { createContext, useContext, useRef, ReactNode } from "react"; + +interface BookCoverContextType { + coverCache: React.RefObject>; +} + +const BookCoverContext = createContext(null); + +interface BookCoverProviderProps { + children: ReactNode; +} + +export function BookCoverProvider({ + children, +}: BookCoverProviderProps): React.ReactElement { + const coverCache = useRef>({}); + + return ( + + {children} + + ); +} + +export function useBookCoverContext(): BookCoverContextType { + const context = useContext(BookCoverContext); + + if (!context) { + throw new Error( + "useBookCoverContext must be used within BookCoverProvider", + ); + } + + return context; +} diff --git a/src/hooks/useBookCover.ts b/src/hooks/useBookCover.ts new file mode 100644 index 0000000..f7edeba --- /dev/null +++ b/src/hooks/useBookCover.ts @@ -0,0 +1,57 @@ +import { useState, useEffect } from "react"; +import { useBookCoverContext } from "@/contexts/BookCoverContext"; + +export interface UseBookCoverResult { + coverUrl: string | null; + loading: boolean; + error: string | null; +} + +export function useBookCover(bookId: number | null): UseBookCoverResult { + const { coverCache } = useBookCoverContext(); + const [coverUrl, setCoverUrl] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (bookId === null) { + setCoverUrl(null); + setLoading(false); + setError(null); + return; + } + + if (coverCache.current[bookId]) { + setCoverUrl(coverCache.current[bookId]); + setLoading(false); + setError(null); + return; + } + + setLoading(true); + setError(null); + + const checkInterval = setInterval(() => { + if (coverCache.current[bookId]) { + setCoverUrl(coverCache.current[bookId]); + setLoading(false); + clearInterval(checkInterval); + } + }, 100); + + const timeout = setTimeout(() => { + if (!coverCache.current[bookId]) { + setError("Cover not available"); + setLoading(false); + clearInterval(checkInterval); + } + }, 10000); + + return () => { + clearInterval(checkInterval); + clearTimeout(timeout); + }; + }, [bookId, coverCache]); + + return { coverUrl, loading, error }; +} diff --git a/src/hooks/useLibrary.ts b/src/hooks/useLibrary.ts new file mode 100644 index 0000000..9ef9290 --- /dev/null +++ b/src/hooks/useLibrary.ts @@ -0,0 +1,124 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { Book } from "@/types/book"; +import { libraryOpen, libraryGetAllBooks } from "@/api/library"; +import { useStreamBookCovers } from "./useStreamBookCovers"; + +const LIBRARY_PATH_KEY = "kikou_library_path"; + +export interface UseLibraryReturn { + books: Book[]; + isLoading: boolean; + error: string | null; + libraryPath: string | null; + openLibrary: (path: string) => Promise; + loadBooks: () => Promise; + isLibraryOpen: boolean; +} + +export function useLibrary(): UseLibraryReturn { + const [books, setBooks] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [libraryPath, setLibraryPath] = useState(null); + const [isLibraryOpen, setIsLibraryOpen] = useState(false); + const autoLoadAttemptedRef = useRef(false); + const isLibraryOpenRef = useRef(false); + + const bookIds = books.map((book) => book.id); + + useStreamBookCovers({ + bookIds, + enabled: books.length > 0, + }); + + // Keep ref in sync with state + useEffect(() => { + isLibraryOpenRef.current = isLibraryOpen; + }, [isLibraryOpen]); + + // Load library path from localStorage on mount + useEffect(() => { + const savedPath = localStorage.getItem(LIBRARY_PATH_KEY); + + if (savedPath) { + setLibraryPath(savedPath); + } + }, []); + + // Open library when path is set + const openLibrary = useCallback(async (path: string) => { + // Mark that auto-load should not trigger for this manual open + autoLoadAttemptedRef.current = true; + + try { + setIsLoading(true); + setError(null); + + const uri = `calibre://${path}`; + + await libraryOpen(uri); + localStorage.setItem(LIBRARY_PATH_KEY, path); + setLibraryPath(path); + setIsLibraryOpen(true); + + isLibraryOpenRef.current = true; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to open library"; + + setError(errorMessage); + setIsLibraryOpen(false); + + isLibraryOpenRef.current = false; + } finally { + setIsLoading(false); + } + }, []); + + // Load books from open library + const loadBooks = useCallback(async () => { + if (!isLibraryOpenRef.current) { + setError("No library opened"); + return; + } + + try { + setIsLoading(true); + setError(null); + + const loadedBooks = await libraryGetAllBooks(); + + setBooks(loadedBooks); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to load books"; + + setError(errorMessage); + } finally { + setIsLoading(false); + } + }, []); + + // Auto-load library and books when libraryPath is loaded from localStorage + useEffect(() => { + if (libraryPath && !autoLoadAttemptedRef.current) { + autoLoadAttemptedRef.current = true; + openLibrary(libraryPath).then(() => { + // After opening, load books + setTimeout(() => { + loadBooks(); + }, 100); + }); + } + }, [libraryPath, openLibrary, loadBooks]); + + return { + books, + isLoading, + error, + libraryPath, + openLibrary, + loadBooks, + isLibraryOpen, + }; +} diff --git a/src/hooks/useStreamBookCovers.ts b/src/hooks/useStreamBookCovers.ts new file mode 100644 index 0000000..db67377 --- /dev/null +++ b/src/hooks/useStreamBookCovers.ts @@ -0,0 +1,111 @@ +import { useEffect, useCallback } from "react"; +import { useBookCoverContext } from "@/contexts/BookCoverContext"; +import { streamBookCovers, CoverStreamEvent } from "@/api/library"; +import { devLog } from "@/utils/devLog"; + +export interface UseStreamBookCoversOptions { + bookIds: number[]; + enabled?: boolean; + onProgress?: (loaded: number, total: number) => void; + onFinish?: () => void; + onStart?: () => void; +} + +function createDataUrl(base64Data: string): string { + return `data:image/jpeg;base64,${base64Data}`; +} + +export function useStreamBookCovers({ + bookIds, + enabled = true, + onProgress, + onFinish, + onStart, +}: UseStreamBookCoversOptions): { startStreaming: () => Promise } { + const { coverCache } = useBookCoverContext(); + + const startStreaming = useCallback(async (): Promise => { + if (!enabled || bookIds.length === 0) return; + + const uncachedIds = bookIds.filter((bookId) => !coverCache.current[bookId]); + + if (uncachedIds.length === 0) { + devLog("All book covers already cached"); + onFinish?.(); + + return; + } + + devLog(`Streaming ${uncachedIds.length} book covers`); + + let loaded = bookIds.length - uncachedIds.length; + const total = bookIds.length; + + try { + await streamBookCovers(uncachedIds, (event: CoverStreamEvent) => { + switch (event.event) { + case "started": + if ( + event.data && + typeof event.data === "object" && + "total_books" in event.data + ) { + devLog(`Started streaming ${event.data.total_books} covers`); + onStart?.(); + } + + break; + + case "cover": { + if ( + event.data && + typeof event.data === "object" && + "book_id" in event.data && + "data_base64" in event.data + ) { + const { book_id, data_base64 } = event.data; + + coverCache.current[book_id] = createDataUrl(data_base64); + loaded++; + + devLog(`Cached cover for book ${book_id} (${loaded}/${total})`); + + onProgress?.(loaded, total); + } + + break; + } + + case "error": + if ( + event.data && + typeof event.data === "object" && + "book_id" in event.data && + "message" in event.data + ) { + devLog( + `Error loading cover for book ${event.data.book_id}: ${event.data.message}`, + ); + loaded++; + onProgress?.(loaded, total); + } + + break; + + case "finished": + devLog("Cover streaming finished"); + onFinish?.(); + break; + } + }); + } catch (error) { + devLog("Cover streaming error:", error); + } + }, [bookIds, enabled, coverCache, onProgress, onFinish, onStart]); + + useEffect(() => { + startStreaming(); + }, [startStreaming]); + + return { startStreaming }; +} diff --git a/src/hooks/useTableColumns.ts b/src/hooks/useTableColumns.ts new file mode 100644 index 0000000..9a9c405 --- /dev/null +++ b/src/hooks/useTableColumns.ts @@ -0,0 +1,99 @@ +import { useState, useEffect, useCallback } from "react"; + +export interface ColumnConfig { + id: string; + label: string; + visible: boolean; + order: number; +} + +const TABLE_COLUMNS_KEY = "kikou_table_columns"; + +const DEFAULT_COLUMNS: ColumnConfig[] = [ + { id: "cover", label: "Cover", visible: true, order: 0 }, + { id: "title", label: "Title", visible: true, order: 1 }, + { id: "authors", label: "Authors", visible: true, order: 2 }, + { id: "series", label: "Series", visible: true, order: 3 }, + { id: "publisher", label: "Publisher", visible: true, order: 4 }, + { id: "pubdate", label: "Published", visible: true, order: 5 }, + { id: "tags", label: "Tags", visible: true, order: 6 }, + { id: "isbn", label: "ISBN", visible: false, order: 7 }, + { id: "languages", label: "Languages", visible: false, order: 8 }, + { id: "formats", label: "Formats", visible: false, order: 9 }, + { id: "rating", label: "Rating", visible: false, order: 10 }, +]; + +export interface UseTableColumnsReturn { + columns: ColumnConfig[]; + visibleColumns: ColumnConfig[]; + toggleColumnVisibility: (columnId: string) => void; + reorderColumns: (columnId: string, newOrder: number) => void; + resetColumns: () => void; +} + +export function useTableColumns(): UseTableColumnsReturn { + const [columns, setColumns] = useState(DEFAULT_COLUMNS); + + // Load columns from localStorage on mount + useEffect(() => { + const saved = localStorage.getItem(TABLE_COLUMNS_KEY); + + if (saved) { + try { + const parsedColumns = JSON.parse(saved) as ColumnConfig[]; + + setColumns(parsedColumns); + } catch { + // If parsing fails, use defaults + setColumns(DEFAULT_COLUMNS); + } + } + }, []); + + // Save columns to localStorage whenever they change + useEffect(() => { + localStorage.setItem(TABLE_COLUMNS_KEY, JSON.stringify(columns)); + }, [columns]); + + const toggleColumnVisibility = useCallback((columnId: string) => { + setColumns((prev) => + prev.map((col) => + col.id === columnId ? { ...col, visible: !col.visible } : col, + ), + ); + }, []); + + const reorderColumns = useCallback((columnId: string, newOrder: number) => { + setColumns((prev) => { + const updated = [...prev]; + const columnIndex = updated.findIndex((col) => col.id === columnId); + + if (columnIndex === -1) { + return prev; + } + + const column = updated[columnIndex]; + + updated.splice(columnIndex, 1); + updated.splice(newOrder, 0, column); + + // Recalculate all orders + return updated.map((col, index) => ({ ...col, order: index })); + }); + }, []); + + const resetColumns = useCallback(() => { + setColumns(DEFAULT_COLUMNS); + localStorage.removeItem(TABLE_COLUMNS_KEY); + }, []); + + const visibleColumns = columns.filter((col) => col.visible); + + return { + columns, + visibleColumns, + toggleColumnVisibility, + reorderColumns, + resetColumns, + }; +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index fa134ff..e81416b 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -19,7 +19,7 @@ function AppContent({ Component, pageProps, router }) { return getPageWrapper({ path, pathname: router.pathname, content }); } -export default function App(appProps: AppProps) { +export default function App(appProps: AppProps): React.ReactElement { return ( diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 61804d8..483c175 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,16 +1,32 @@ "use client"; -import { useRouter } from "next/router"; -import { Box, Button, Typography, Card } from "@mui/joy"; +import { useState } from "react"; +import { Box, Button, Typography, Card, Alert, Stack } from "@mui/joy"; import { useResetNavigation } from "@/hooks/useResetNavigation"; +import { useLibrary } from "@/hooks/useLibrary"; +import { useTableColumns } from "@/hooks/useTableColumns"; +import { BooksTable } from "@/components/BooksTable"; +import { OpenLibraryDialog } from "@/components/OpenLibraryDialog"; +import LoadingOverlay from "@/components/ui/LoadingOverlay"; -export default function Page() { - const router = useRouter(); +export default function HomePage() { + const [openLibraryDialogOpen, setOpenLibraryDialogOpen] = useState(false); + + const { books, isLoading, error, libraryPath, openLibrary, isLibraryOpen } = + useLibrary(); + + const { columns, toggleColumnVisibility, reorderColumns, resetColumns } = + useTableColumns(); useResetNavigation(); - return ( - <> + const handleOpenLibrary = async (path: string) => { + await openLibrary(path); + }; + + // Show empty state when no library path is known + if (!libraryPath) { + return ( Kikou - + + Welcome to Kikou. Start by opening a Calibre library to view and + manage your books. + + + + + + setOpenLibraryDialogOpen(false)} + onLibrarySelected={handleOpenLibrary} + isLoading={isLoading} + /> + + ); + } + + // Show library view (even while loading or if there's an error) + return ( + <> + + + + + + Library + + + + {libraryPath && ( + + Location: {libraryPath} + + )} + + + {error && ( + + {error} + + )} + + + + + + setOpenLibraryDialogOpen(false)} + onLibrarySelected={handleOpenLibrary} + isLoading={isLoading} + /> ); diff --git a/src/styles/globals.css b/src/styles/globals.css index f25e9ab..820e426 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -66,3 +66,39 @@ textarea, -moz-user-select: text !important; -ms-user-select: text !important; } + +/* Enable scrolling for table container but not cells */ +.table-container { + overflow: auto !important; +} + +table td, +table th { + overflow: hidden !important; + user-select: text !important; + -webkit-user-select: text !important; + -moz-user-select: text !important; + -ms-user-select: text !important; +} + +/* Column resize handle */ +.resize-handle { + position: absolute; + top: 0; + right: 0; + width: 4px; + height: 100%; + cursor: col-resize; + user-select: none !important; + -webkit-user-select: none !important; + -moz-user-select: none !important; + -ms-user-select: none !important; +} + +.resize-handle:hover { + background-color: var(--joy-palette-primary-500, #0b6bcb); +} + +.resize-handle.resizing { + background-color: var(--joy-palette-primary-600, #185ea5); +} diff --git a/src/test-utils/testUtils.tsx b/src/test-utils/testUtils.tsx index b270a89..43b3bb8 100644 --- a/src/test-utils/testUtils.tsx +++ b/src/test-utils/testUtils.tsx @@ -27,6 +27,7 @@ import React from "react"; import { render } from "@testing-library/react"; import { CssVarsProvider } from "@mui/joy"; import { ThemeProvider } from "../contexts/ThemeContext"; +import { BookCoverProvider } from "../contexts/BookCoverContext"; // Mock Tauri APIs jest.mock("@tauri-apps/api/core", () => ({ @@ -47,7 +48,9 @@ jest.mock("@tauri-apps/api/app", () => ({ export function renderWithProviders(ui: React.ReactElement) { return render( - {ui} + + {ui} + , ); } diff --git a/src/types/book.ts b/src/types/book.ts new file mode 100644 index 0000000..555bcad --- /dev/null +++ b/src/types/book.ts @@ -0,0 +1,36 @@ +export interface Author { + id: number; + name: string; + sort: string; + link?: string; +} + +export interface Series { + id: number; + name: string; +} + +export interface Tag { + id: number; + name: string; +} + +export interface Identifier { + book_id: number; + kind: string; + val: string; +} + +export interface Book { + id: number; + title: string; + pubdate: string; + isbn: string; + authors: Author[]; + publishers: string[]; + tags: Tag[]; + series: Series | null; + rating: number | null; + formats: string[]; + languages: string[]; +} diff --git a/third_party/calibre b/third_party/calibre new file mode 160000 index 0000000..93399c3 --- /dev/null +++ b/third_party/calibre @@ -0,0 +1 @@ +Subproject commit 93399c33cdbaa885174e6a74364a3f5ce667506b