diff --git a/.gitignore b/.gitignore index 18b7a2b..9f27e82 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ rust-project.json /.direnv/ +# Python script test artifacts +__pycache__/ +*.py[cod] + # Hugo build output /website/public/ /website/.hugo_build.lock diff --git a/Cargo.lock b/Cargo.lock index 2f6e4e9..0d2fff6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5135,6 +5135,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "unicode-casefold", + "unicode-normalization", "wuff", "zip", ] diff --git a/Makefile b/Makefile index a0e10fb..a219c84 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,14 @@ -.PHONY: dev lint fmt test test-scripts check-rfds changelog next-version +.PHONY: dev reset lint fmt test test-scripts check-rfds changelog next-version + +DEV_DATA_HOME := $(CURDIR)/target ## Run the application in debug mode dev: - SHOSAI_DEV_BUILD=1 cargo run -p shosai-app + XDG_DATA_HOME="$(DEV_DATA_HOME)" SHOSAI_DEV_BUILD=1 cargo run -p shosai-app + +## Delete development-only Shosai data and development-owned managed copies +reset: + @XDG_DATA_HOME="$(DEV_DATA_HOME)" python3 scripts/reset-local-data.py ## Run clippy lints on the workspace lint: diff --git a/README.md b/README.md index b3cafb2..59277dd 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,17 @@ make test make check-rfds ``` +`make dev` stores its isolated database, preferences, and default managed book +copies in `target/shosai-dev`; packaged releases use the platform `shosai` data +directory. Deleting `target` or running `cargo clean` therefore also clears the +default development state. With the development app closed, `make reset` removes +only the ownership-marked `target/shosai-dev` state. Production data and books +referenced from their current location are preserved. Copied books in an external +development managed-library folder are removed only when it contains +`.shosai-storage-profile` with the exact value `shosai-development-v1`; the folder +itself and unrelated files in it are preserved. Reset fails safely if that marker +is absent or does not match. + The project is a Cargo workspace: - `crates/shosai-core` contains document formats, library storage, and reader logic. diff --git a/crates/shosai-app/locales/en-US/main.ftl b/crates/shosai-app/locales/en-US/main.ftl index 1af06c3..3235b32 100644 --- a/crates/shosai-app/locales/en-US/main.ftl +++ b/crates/shosai-app/locales/en-US/main.ftl @@ -62,10 +62,25 @@ page-number = Page { $page } search-library-placeholder = Search by title or author... add-books = + Add books adding-books = Adding books… +adding-books-progress = Adding { $completed } of { $total } books… add-books-heading = Add books add-books-description = Choose individual files or a folder. Shosai supports PDF, EPUB, and CBZ files. choose-book-files = Choose files… choose-book-folder = Choose a folder… +scanning-books = Checking books… +scanning-books-description = Shosai is finding supported formats and exact duplicates. Nothing will be added until you confirm. +finding-books-progress = Finding supported books… { $found } found · { $read } read +reading-books-progress = Reading { $completed } of { $total } books… +checking-copies-progress = Checking { $completed } of { $total } books for copies… +review-books-heading = Review books +review-books-summary = Found { $found } supported files · { $selected } selected +filter-review-books-placeholder = Filter by title, file, or format… +no-matching-books-found = No books match this filter. +select-all-new-books = Select all new books +no-supported-books-found = No supported books were found in this selection. +already-in-library = Already in your library as “{ $title }” +duplicate-selected-file = Exact duplicate of { $file } +book-discovery-failed = { $count } files could not be checked. { $file }: { $error } book-storage-heading = How should Shosai store these books? selected-book-files = { $count } files selected selected-book-folder = Folder selected: { $folder } @@ -73,7 +88,6 @@ copy-into-shosai = Copy into Shosai — Recommended copy-into-shosai-description = Keeps a private copy that remains available if the originals move or are deleted. Uses additional disk space. use-current-location = Use files in their current location use-current-location-description = Avoids duplicate storage, but books become unavailable if the files move or their drive is disconnected. -ready-to-add-books = Ready to add books change-storage-choice = Change storage choice add-selected-books = Add books back = Back diff --git a/crates/shosai-app/locales/ja/main.ftl b/crates/shosai-app/locales/ja/main.ftl index 5bec3b6..f4ddc41 100644 --- a/crates/shosai-app/locales/ja/main.ftl +++ b/crates/shosai-app/locales/ja/main.ftl @@ -62,10 +62,25 @@ page-number = { $page }ページ search-library-placeholder = タイトル・著者を検索... add-books = + 本を追加 adding-books = 本を追加中… +adding-books-progress = { $total }冊中{ $completed }冊を追加中… add-books-heading = 本を追加 add-books-description = ファイルまたはフォルダーを選んでください。PDF、EPUB、CBZファイルに対応しています。 choose-book-files = ファイルを選択… choose-book-folder = フォルダーを選択… +scanning-books = 本を確認中… +scanning-books-description = 対応形式と完全に同じファイルを確認しています。確定するまで本は追加されません。 +finding-books-progress = 対応している本を検索中… { $found }冊見つかりました · { $read }冊読み込み済み +reading-books-progress = { $total }冊中{ $completed }冊を読み込み中… +checking-copies-progress = 同じ本がないか{ $total }冊中{ $completed }冊を確認中… +review-books-heading = 追加する本を確認 +review-books-summary = 対応ファイル:{ $found }個 · 選択中:{ $selected }個 +filter-review-books-placeholder = タイトル、ファイル名、形式で絞り込む… +no-matching-books-found = 条件に一致する本がありません。 +select-all-new-books = 新しい本をすべて選択 +no-supported-books-found = 対応している本が見つかりませんでした。 +already-in-library = 「{ $title }」としてライブラリに追加済み +duplicate-selected-file = { $file }と完全に同じファイル +book-discovery-failed = { $count }個のファイルを確認できませんでした。{ $file }: { $error } book-storage-heading = 本をどこに保存しますか? selected-book-files = { $count }個のファイルを選択中 selected-book-folder = 選択中のフォルダー:{ $folder } @@ -73,7 +88,6 @@ copy-into-shosai = 書斎にコピー — おすすめ copy-into-shosai-description = 元のファイルを移動または削除しても読めるよう、専用のコピーを保存します。追加のディスク容量を使用します。 use-current-location = 現在の場所にあるファイルを使用 use-current-location-description = ファイルを複製しませんが、移動した場合やドライブが接続されていない場合は読めなくなります。 -ready-to-add-books = 本を追加する準備ができました change-storage-choice = 保存方法を変更 add-selected-books = 本を追加 back = 戻る diff --git a/crates/shosai-app/src/app.rs b/crates/shosai-app/src/app.rs index dd09638..254be0b 100644 --- a/crates/shosai-app/src/app.rs +++ b/crates/shosai-app/src/app.rs @@ -1,12 +1,12 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use iced::advanced::widget::{Id as WidgetId, operation}; use iced::keyboard; use iced::widget::{ - button, center, column, container, grid, image, mouse_area, opaque, pick_list, responsive, row, - scrollable, sensor, text_input, + button, center, checkbox, column, container, grid, image, mouse_area, opaque, pick_list, + responsive, row, scrollable, sensor, text_input, }; use iced::{Element, Length, Point, Size, Subscription, Task, window}; use tokio::sync::{mpsc, oneshot}; @@ -16,7 +16,9 @@ use shosai_core::cbz::CbzDoc; use shosai_core::document::{Document, RenderedPage}; use shosai_core::epub::EpubDoc; use shosai_core::library::{ - Book, BookPage, ImportReport, Library, ManagedPathChange, ManagedStorageSummary, + Book, BookPage, ImportCancellation, ImportCandidate, ImportDiscoveryProgress, + ImportDiscoveryProgressSnapshot, ImportDuplicate, ImportFailure, ImportReport, Library, + ManagedPathChange, ManagedStorageSummary, PreparedManagedImport, }; use shosai_core::pdf::PdfDoc; use shosai_core::reading_state::{FileReadingState, ReadingStateStore}; @@ -65,6 +67,11 @@ const DEFAULT_READER_THEME_KEY: &str = "reader.default_theme"; const DEFAULT_EPUB_FONT_SIZE_KEY: &str = "reader.default_epub_font_size"; const DEFAULT_EPUB_LINE_SPACING_KEY: &str = "reader.default_epub_line_spacing"; const DEFAULT_PDF_ZOOM_KEY: &str = "reader.default_pdf_zoom"; +const MANAGED_IMPORT_PREPARATION_CONCURRENCY: usize = 4; +const REVIEW_GROUP_ROW_HEIGHT: f32 = 28.0; +const REVIEW_BOOK_ROW_HEIGHT: f32 = 58.0; +const REVIEW_VIRTUAL_OVERSCAN: f32 = 180.0; +const REVIEW_DEFAULT_VIEWPORT_HEIGHT: f32 = 420.0; #[derive(Debug, Clone, PartialEq, Eq)] struct SelectOption { @@ -213,6 +220,27 @@ enum AddBooksSource { Folder(PathBuf), } +#[derive(Debug, Clone)] +struct StagedImport { + candidate: ImportCandidate, + selected: bool, +} + +#[derive(Debug, Clone)] +enum AddBooksReviewRow { + Group(String), + Book(usize), +} + +impl AddBooksReviewRow { + fn height(&self) -> f32 { + match self { + Self::Group(_) => REVIEW_GROUP_ROW_HEIGHT, + Self::Book(_) => REVIEW_BOOK_ROW_HEIGHT, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) enum AddBookBehavior { #[default] @@ -669,8 +697,30 @@ pub struct State { removing_book: Option, add_books_open: bool, add_books_source: Option, - add_books_override: bool, + add_books_discovering: bool, + add_books_generation: u64, + add_books_cancellation: Option, + add_books_progress: Option, + staged_imports: Vec, + add_books_review_search: String, + add_books_review_rows: Vec, + add_books_review_revision: u64, + add_books_review_offset: f32, + add_books_review_viewport_height: f32, + import_discovery_failures: Vec, + add_books_copy: Option, adding_books: bool, + pending_book_imports: VecDeque<(usize, ImportCandidate)>, + prepared_book_imports: + BTreeMap), ImportFailure>>, + book_import_preparing: usize, + book_import_next_commit: usize, + book_import_committing: bool, + book_import_copy: bool, + book_import_prepared: usize, + book_import_completed: usize, + book_import_total: usize, + book_import_report: ImportReport, library_error: Option, // -- Settings state -- @@ -779,8 +829,29 @@ pub fn boot() -> (State, Task) { removing_book: None, add_books_open: false, add_books_source: None, - add_books_override: false, + add_books_discovering: false, + add_books_generation: 0, + add_books_cancellation: None, + add_books_progress: None, + staged_imports: Vec::new(), + add_books_review_search: String::new(), + add_books_review_rows: Vec::new(), + add_books_review_revision: 0, + add_books_review_offset: 0.0, + add_books_review_viewport_height: REVIEW_DEFAULT_VIEWPORT_HEIGHT, + import_discovery_failures: Vec::new(), + add_books_copy: None, adding_books: false, + pending_book_imports: VecDeque::new(), + prepared_book_imports: BTreeMap::new(), + book_import_preparing: 0, + book_import_next_commit: 0, + book_import_committing: false, + book_import_copy: false, + book_import_prepared: 0, + book_import_completed: 0, + book_import_total: 0, + book_import_report: ImportReport::default(), library_error: None, add_book_behavior: AddBookBehavior::default(), reader_defaults: ReaderDefaults::default(), @@ -833,6 +904,10 @@ pub fn boot() -> (State, Task) { .await .map(PathBuf::from) .unwrap_or_else(|| store.managed_books_dir()); + if managed_books_dir != store.managed_books_dir() { + shosai_core::reading_state::validate_managed_library_directory(&managed_books_dir) + .map_err(|error| error.to_string())?; + } let add_book_behavior = AddBookBehavior::from_stored( store.get_pref_async(ADD_BOOK_BEHAVIOR_KEY).await.as_deref(), ); @@ -966,7 +1041,9 @@ fn library_load_sensor_key(state: &State) -> Option<(u64, usize)> { fn library_activity_active(state: &State) -> bool { state.screen == Screen::Library - && (state.adding_books || (state.library_loading && state.library_offset == 0)) + && (state.add_books_discovering + || state.adding_books + || (state.library_loading && state.library_offset == 0)) } fn capture_reader_tab(state: &State) -> Option { @@ -3672,7 +3749,15 @@ fn library_header(state: &State, compact: bool) -> Element<'_, Message> { let search = container(search_input).width(Length::Fill).max_width(380); let add_message = (state.library.is_some() && !state.adding_books && !state.moving_library) .then_some(Message::OpenAddBooks); - let add_label = if state.adding_books { + let add_label = if state.adding_books && state.book_import_total > 0 { + state.i18n.text_with_args( + "adding-books-progress", + [ + ("completed", (state.book_import_completed as i64).into()), + ("total", (state.book_import_total as i64).into()), + ], + ) + } else if state.adding_books { state.i18n.text("adding-books") } else { state.i18n.text("add-books") @@ -3710,10 +3795,11 @@ fn library_header(state: &State, compact: bool) -> Element<'_, Message> { .into() }; - let activity_active = library_activity_active(state); + let activity: Element<'_, Message> = + widgets::reading_progress(f64::from(state.library_activity_progress)).into(); let header = column![ container(content).padding([16, 20]).width(Length::Fill), - widgets::activity_bar(activity_active, state.library_activity_progress), + activity, ]; container(header) @@ -4435,101 +4521,424 @@ fn format_bytes(bytes: u64) -> String { } } +fn import_candidate_label(source: &AddBooksSource, path: &Path) -> String { + match source { + AddBooksSource::Folder(root) => { + let root = root.canonicalize().unwrap_or_else(|_| root.clone()); + path.strip_prefix(root) + .unwrap_or(path) + .display() + .to_string() + } + AddBooksSource::Files(_) => path.display().to_string(), + } +} + +fn rebuild_add_books_review_rows(state: &mut State) { + let query = shosai_core::library::normalize_import_text(&state.add_books_review_search); + state.add_books_review_rows.clear(); + let mut previous_group = None; + for (index, staged) in state.staged_imports.iter().enumerate() { + let candidate = &staged.candidate; + if !query.is_empty() + && !shosai_core::library::normalize_import_text(&candidate.title).contains(&query) + && !shosai_core::library::normalize_import_text(&candidate.path.to_string_lossy()) + .contains(&query) + && !candidate.format.as_str().contains(&query) + { + continue; + } + if previous_group != Some(candidate.group_key.as_str()) { + state + .add_books_review_rows + .push(AddBooksReviewRow::Group(candidate.title.clone())); + previous_group = Some(candidate.group_key.as_str()); + } + state + .add_books_review_rows + .push(AddBooksReviewRow::Book(index)); + } + state.add_books_review_revision = state.add_books_review_revision.wrapping_add(1); + state.add_books_review_offset = 0.0; +} + +fn virtual_review_range( + rows: &[AddBooksReviewRow], + offset: f32, + viewport_height: f32, +) -> (std::ops::Range, f32, f32) { + let total_height: f32 = rows.iter().map(AddBooksReviewRow::height).sum(); + let viewport_height = viewport_height.max(1.0); + let offset = offset.clamp(0.0, (total_height - viewport_height).max(0.0)); + let visible_start = (offset - REVIEW_VIRTUAL_OVERSCAN).max(0.0); + let visible_end = offset + viewport_height + REVIEW_VIRTUAL_OVERSCAN; + let mut top = 0.0; + let mut start = 0; + while start < rows.len() && top + rows[start].height() < visible_start { + top += rows[start].height(); + start += 1; + } + let mut end = start; + let mut rendered_height = 0.0; + while end < rows.len() && top + rendered_height < visible_end { + rendered_height += rows[end].height(); + end += 1; + } + let bottom = (total_height - top - rendered_height).max(0.0); + (start..end, top, bottom) +} + +fn discovery_progress_value(current: f32, progress: ImportDiscoveryProgressSnapshot) -> f32 { + if !progress.enumerating && progress.completed_files >= progress.total_files { + return 1.0; + } + let work = progress.hashed_files + progress.completed_files; + let target = if progress.enumerating { + 0.45 * work as f32 / (work + 8).max(1) as f32 + } else { + 0.5 + 0.5 * work as f32 / (progress.total_files.max(1) * 2) as f32 + }; + current.max(target).min(0.99) +} + fn add_books_modal(state: &State) -> Element<'_, Message> { let cancel = button(text(state.i18n.text("cancel"))) .on_press(Message::CancelAddBooks) .padding([8, 14]) .style(app_theme::book_card_action); - let content: Element<'_, Message> = if let Some(source) = &state.add_books_source { - let selection = match source { - AddBooksSource::Files(paths) => state.i18n.text_with_args( - "selected-book-files", - [("count", (paths.len() as i64).into())], - ), - AddBooksSource::Folder(path) => { - let name = path + let selection = state.add_books_source.as_ref().map(|source| match source { + AddBooksSource::Files(paths) => state.i18n.text_with_args( + "selected-book-files", + [("count", (paths.len() as i64).into())], + ), + AddBooksSource::Folder(path) => { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + state + .i18n + .text_with_args("selected-book-folder", [("folder", name.into())]) + } + }); + + let content: Element<'_, Message> = if state.add_books_discovering { + let progress = state + .add_books_progress + .as_ref() + .map(ImportDiscoveryProgress::snapshot) + .unwrap_or(shosai_core::library::ImportDiscoveryProgressSnapshot { + enumerating: true, + hashed_files: 0, + completed_files: 0, + total_files: 0, + }); + let progress_label = if progress.enumerating { + state.i18n.text_with_args( + "finding-books-progress", + [ + ("found", (progress.total_files as i64).into()), + ("read", (progress.hashed_files as i64).into()), + ], + ) + } else if progress.hashed_files < progress.total_files { + state.i18n.text_with_args( + "reading-books-progress", + [ + ("completed", (progress.hashed_files as i64).into()), + ("total", (progress.total_files as i64).into()), + ], + ) + } else { + state.i18n.text_with_args( + "checking-copies-progress", + [ + ("completed", (progress.completed_files as i64).into()), + ("total", (progress.total_files as i64).into()), + ], + ) + }; + let progress_value = if progress.enumerating { + f64::from(discovery_progress_value( + state.library_activity_progress, + progress, + )) + } else { + f64::from(state.library_activity_progress) + }; + let progress_bar: Element<'_, Message> = widgets::reading_progress(progress_value).into(); + column![ + text(state.i18n.text("scanning-books")).size(20), + text(selection.unwrap_or_default()) + .size(13) + .color(app_theme::TEXT_MUTED), + text(state.i18n.text("scanning-books-description")) + .size(12) + .color(app_theme::TEXT_MUTED), + text(progress_label).size(12), + progress_bar, + row![ + button(text(state.i18n.text("back"))) + .on_press(Message::ClearAddBooksSelection) + .padding([8, 14]) + .style(app_theme::book_card_action), + iced::widget::Space::new().width(Length::Fill), + cancel, + ], + ] + .spacing(12) + .into() + } else if let Some(source) = &state.add_books_source { + let selected_count = state + .staged_imports + .iter() + .filter(|staged| staged.selected) + .count(); + let new_count = state + .staged_imports + .iter() + .filter(|staged| staged.candidate.duplicate.is_none()) + .count(); + let all_new_selected = new_count > 0 + && state + .staged_imports + .iter() + .filter(|staged| staged.candidate.duplicate.is_none()) + .all(|staged| staged.selected); + let (visible_rows, top_spacer, bottom_spacer) = virtual_review_range( + &state.add_books_review_rows, + state.add_books_review_offset, + state.add_books_review_viewport_height, + ); + let mut candidates = column![]; + if top_spacer > 0.0 { + candidates = candidates.push(iced::widget::Space::new().height(top_spacer)); + } + for row in &state.add_books_review_rows[visible_rows] { + let AddBooksReviewRow::Book(index) = row else { + let AddBooksReviewRow::Group(title) = row else { + unreachable!(); + }; + candidates = candidates.push( + container(text(title.clone()).size(14)) + .height(REVIEW_GROUP_ROW_HEIGHT) + .align_y(iced::Alignment::End), + ); + continue; + }; + let staged = &state.staged_imports[*index]; + let label = import_candidate_label(source, &staged.candidate.path); + let label_font = typography::font_for_text(&label); + let badge = container( + text(staged.candidate.format.as_str().to_uppercase()) + .size(10) + .font(state.i18n.ui_font()), + ) + .padding([3, 6]) + .style(|_| { + iced::widget::container::Style::default() + .background(app_theme::SURFACE_MUTED) + .border(iced::Border { + color: app_theme::BORDER, + width: 1.0, + radius: app_theme::RADIUS_SMALL.into(), + }) + }); + let details = row![ + checkbox(staged.selected) + .label(label) + .font(label_font) + .width(Length::Fill) + .on_toggle(move |selected| Message::ToggleStagedBook(*index, selected)), + badge, + text(format_bytes(staged.candidate.file_size)) + .size(11) + .color(app_theme::TEXT_MUTED), + ] + .spacing(8) + .align_y(iced::Alignment::Center); + let duplicate: Element<'_, Message> = match &staged.candidate.duplicate { + Some(ImportDuplicate::ExistingBook { title, .. }) => text( + state + .i18n + .text_with_args("already-in-library", [("title", title.clone().into())]), + ) + .size(11) + .color(app_theme::TEXT_MUTED) + .into(), + Some(ImportDuplicate::SelectedFile { path }) => { + let file = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + text( + state + .i18n + .text_with_args("duplicate-selected-file", [("file", file.into())]), + ) + .size(11) + .color(app_theme::TEXT_MUTED) + .into() + } + None => iced::widget::Space::new().height(0).into(), + }; + candidates = candidates.push( + container(column![details, duplicate].spacing(2).width(Length::Fill)) + .padding([7, 8]) + .width(Length::Fill) + .height(REVIEW_BOOK_ROW_HEIGHT) + .style(app_theme::surface), + ); + } + if bottom_spacer > 0.0 { + candidates = candidates.push(iced::widget::Space::new().height(bottom_spacer)); + } + + let discovery_error: Element<'_, Message> = + if let Some(failure) = state.import_discovery_failures.first() { + let file = failure + .path .file_name() .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()); - state - .i18n - .text_with_args("selected-book-folder", [("folder", name.into())]) - } - }; - if state.add_book_behavior == AddBookBehavior::Ask || state.add_books_override { + .unwrap_or_else(|| failure.path.display().to_string()); + text(state.i18n.text_with_args( + "book-discovery-failed", + [ + ( + "count", + (state.import_discovery_failures.len() as i64).into(), + ), + ("file", file.into()), + ("error", failure.error.clone().into()), + ], + )) + .size(11) + .color(app_theme::TEXT_MUTED) + .into() + } else { + iced::widget::Space::new().height(0).into() + }; + + let storage: Element<'_, Message> = if let Some(copy) = state.add_books_copy { + let behavior = if copy { + state.i18n.text("copy-into-shosai") + } else { + state.i18n.text("use-current-location") + }; column![ - text(state.i18n.text("book-storage-heading")).size(20), - text(selection).size(13).color(app_theme::TEXT_MUTED), - widgets::primary_button( + text(state.i18n.text("book-storage-heading")).size(14), + row![ + text(behavior).size(12), + button(text(state.i18n.text("change-storage-choice"))) + .on_press(Message::ChangeAddBooksStorage) + .padding([4, 8]) + .style(app_theme::book_card_action), + ] + .spacing(8) + .align_y(iced::Alignment::Center), + ] + .spacing(4) + .into() + } else { + column![ + text(state.i18n.text("book-storage-heading")).size(14), + widgets::secondary_button( state.i18n.text("copy-into-shosai"), - Some(Message::AddSelectedBooks { copy: true }), + Some(Message::SelectAddBooksStorage(true)), state.i18n.ui_font(), ) .width(Length::Fill), text(state.i18n.text("copy-into-shosai-description")) - .size(12) + .size(11) .color(app_theme::TEXT_MUTED), widgets::secondary_button( state.i18n.text("use-current-location"), - Some(Message::AddSelectedBooks { copy: false }), + Some(Message::SelectAddBooksStorage(false)), state.i18n.ui_font(), ) .width(Length::Fill), text(state.i18n.text("use-current-location-description")) - .size(12) + .size(11) .color(app_theme::TEXT_MUTED), - row![ - button(text(state.i18n.text("back"))) - .on_press(Message::ClearAddBooksSelection) - .padding([8, 14]) - .style(app_theme::book_card_action), - iced::widget::Space::new().width(Length::Fill), - cancel, - ] - .align_y(iced::Alignment::Center), ] - .spacing(12) + .spacing(6) .into() - } else { - let copy = state.add_book_behavior == AddBookBehavior::Copy; - let behavior = if copy { - state.i18n.text("copy-into-shosai") - } else { - state.i18n.text("use-current-location") - }; - let description = if copy { - state.i18n.text("copy-into-shosai-description") + }; + + let add_message = (selected_count > 0 && state.add_books_copy.is_some()) + .then_some(Message::AddSelectedBooks); + column![ + text(state.i18n.text("review-books-heading")).size(20), + text(state.i18n.text_with_args( + "review-books-summary", + [ + ("found", (state.staged_imports.len() as i64).into()), + ("selected", (selected_count as i64).into()), + ], + )) + .size(12) + .color(app_theme::TEXT_MUTED), + text_input( + &state.i18n.text("filter-review-books-placeholder"), + &state.add_books_review_search, + ) + .on_input(Message::AddBooksReviewSearchChanged) + .padding([8, 10]), + checkbox(all_new_selected) + .label(state.i18n.text("select-all-new-books")) + .font(state.i18n.ui_font()) + .on_toggle(Message::SelectAllStagedBooks), + if state.add_books_review_rows.is_empty() { + container(text(state.i18n.text(if state.staged_imports.is_empty() { + "no-supported-books-found" + } else { + "no-matching-books-found" + }))) + .padding(12) + .width(Length::Fill) } else { - state.i18n.text("use-current-location-description") - }; - column![ - text(state.i18n.text("ready-to-add-books")).size(20), - text(selection).size(13).color(app_theme::TEXT_MUTED), - text(behavior).size(15), - text(description).size(12).color(app_theme::TEXT_MUTED), - button(text(state.i18n.text("change-storage-choice"))) - .on_press(Message::ChangeAddBooksStorage) - .padding([8, 0]) + container( + scrollable( + container(candidates) + .padding(iced::Padding { + right: 18.0, + ..iced::Padding::default() + }) + .width(Length::Fill), + ) + .id(WidgetId::new("add-books-review-scroll")) + .on_scroll(move |viewport| Message::AddBooksReviewScrolled { + generation: state.add_books_generation, + revision: state.add_books_review_revision, + offset: viewport.absolute_offset().y, + viewport_height: viewport.bounds().height, + }) + .height(Length::Fill), + ) + .height(Length::Fill) + }, + discovery_error, + storage, + row![ + button(text(state.i18n.text("back"))) + .on_press(Message::ClearAddBooksSelection) + .padding([8, 14]) .style(app_theme::book_card_action), + iced::widget::Space::new().width(Length::Fill), + cancel, widgets::primary_button( state.i18n.text("add-selected-books"), - Some(Message::AddSelectedBooks { copy }), + add_message, state.i18n.ui_font(), - ) - .width(Length::Fill), - row![ - button(text(state.i18n.text("back"))) - .on_press(Message::ClearAddBooksSelection) - .padding([8, 14]) - .style(app_theme::book_card_action), - iced::widget::Space::new().width(Length::Fill), - cancel, - ], + ), ] - .spacing(12) - .into() - } + .spacing(8) + .align_y(iced::Alignment::Center), + ] + .spacing(10) + .height(Length::Fill) + .into() } else { column![ text(state.i18n.text("add-books-heading")).size(20), @@ -4554,12 +4963,16 @@ fn add_books_modal(state: &State) -> Element<'_, Message> { .into() }; - container(content) + let modal = container(content) .padding(24) .width(Length::Fill) - .max_width(480) - .style(app_theme::modal) - .into() + .max_width(680) + .style(app_theme::modal); + if !state.add_books_discovering && !state.staged_imports.is_empty() { + modal.height(Length::Fill).max_height(760).into() + } else { + modal.into() + } } fn remove_book_modal<'a>(state: &'a State, book: &'a Book) -> Element<'a, Message> { @@ -6590,6 +7003,7 @@ mod tests { fn partial_import_completion_keeps_successes_visible_and_surfaces_failures() { let (mut state, _) = boot(); state.adding_books = true; + state.book_import_total = 1; let report = ImportReport { books: vec![test_book(42)], failures: vec![shosai_core::library::ImportFailure { @@ -6598,7 +7012,7 @@ mod tests { }], }; - let task = update(&mut state, Message::BooksAdded(report)); + let task = update(&mut state, Message::BookAddedToBatch(report)); assert_eq!(task.units(), 0); assert!(!state.adding_books); @@ -6611,8 +7025,135 @@ mod tests { ); } + #[test] + fn review_virtualization_builds_only_the_visible_window() { + let rows: Vec<_> = (0..1_000) + .flat_map(|index| { + [ + AddBooksReviewRow::Group(format!("Book {index}")), + AddBooksReviewRow::Book(index), + ] + }) + .collect(); + let (range, top, bottom) = virtual_review_range(&rows, 30_000.0, 420.0); + let rendered: f32 = rows[range.clone()] + .iter() + .map(AddBooksReviewRow::height) + .sum(); + let total: f32 = rows.iter().map(AddBooksReviewRow::height).sum(); + + assert!(range.len() < 30); + assert!(top > 0.0); + assert!(bottom > 0.0); + assert!((top + rendered + bottom - total).abs() < f32::EPSILON); + } + + #[test] + fn review_search_filters_without_losing_original_book_indices() { + let (mut state, _) = boot(); + state.staged_imports = [ + ("Rust", "guide.epub", shosai_core::library::BookFormat::Epub), + ( + "Systems", + "manual.pdf", + shosai_core::library::BookFormat::Pdf, + ), + ] + .into_iter() + .enumerate() + .map(|(index, (title, path, format))| StagedImport { + selected: true, + candidate: ImportCandidate { + path: PathBuf::from(path), + title: title.to_string(), + group_key: format!("group-{index}"), + format, + file_size: 100, + content_hash: format!("hash-{index}"), + duplicate: None, + }, + }) + .collect(); + state.add_books_review_search = "PDF".to_string(); + + rebuild_add_books_review_rows(&mut state); + + assert_eq!(state.add_books_review_rows.len(), 2); + assert!(matches!( + state.add_books_review_rows[1], + AddBooksReviewRow::Book(1) + )); + } + + #[test] + fn review_search_uses_unicode_normalization_and_case_folding() { + let (mut state, _) = boot(); + state.staged_imports = ["Straße.pdf", "か\u{3099}.epub"] + .into_iter() + .enumerate() + .map(|(index, path)| StagedImport { + selected: true, + candidate: ImportCandidate { + path: PathBuf::from(path), + title: path.to_string(), + group_key: format!("group-{index}"), + format: shosai_core::library::BookFormat::Epub, + file_size: 100, + content_hash: format!("hash-{index}"), + duplicate: None, + }, + }) + .collect(); + + state.add_books_review_search = "STRASSE".to_string(); + rebuild_add_books_review_rows(&mut state); + assert!(matches!( + state.add_books_review_rows[1], + AddBooksReviewRow::Book(0) + )); + + state.add_books_review_search = "が".to_string(); + rebuild_add_books_review_rows(&mut state); + assert!(matches!( + state.add_books_review_rows[1], + AddBooksReviewRow::Book(1) + )); + } + + #[test] + fn virtual_review_range_clamps_stale_offsets_to_short_content() { + let rows = vec![ + AddBooksReviewRow::Group("Book".to_string()), + AddBooksReviewRow::Book(0), + ]; + + let (range, _, _) = virtual_review_range(&rows, 30_000.0, 420.0); + + assert_eq!(range, 0..2); + } + + #[test] + fn stale_review_scroll_message_cannot_replace_the_current_offset() { + let (mut state, _) = boot(); + state.add_books_open = true; + state.add_books_generation = 4; + state.add_books_review_revision = 2; + + let _ = update( + &mut state, + Message::AddBooksReviewScrolled { + generation: 4, + revision: 1, + offset: 30_000.0, + viewport_height: 420.0, + }, + ); + + assert_eq!(state.add_books_review_offset, 0.0); + } + #[tokio::test] - async fn add_books_flow_separates_source_selection_from_storage() { + async fn add_books_flow_stages_candidates_before_importing() { let directory = tempfile::tempdir().unwrap(); let store = ReadingStateStore::open_at_async(&directory.path().join("state.db")) .await @@ -6628,34 +7169,328 @@ mod tests { assert!(state.add_books_source.is_none()); drop(add_books_modal(&state)); + state.add_book_behavior = AddBookBehavior::Copy; let paths = vec![PathBuf::from("one.epub"), PathBuf::from("two.pdf")]; - let _ = update(&mut state, Message::AddBookFilesSelected(paths)); + let generation = state.add_books_generation; + let discover = update( + &mut state, + Message::AddBookFilesSelected { generation, paths }, + ); + assert!(discover.units() > 0); assert!(matches!( state.add_books_source, Some(AddBooksSource::Files(ref paths)) if paths.len() == 2 )); - state.add_book_behavior = AddBookBehavior::Copy; + assert!(state.add_books_discovering); + assert!(state.add_books_progress.is_some()); + assert!(state.staged_imports.is_empty()); + drop(add_books_modal(&state)); + + let generation = state.add_books_generation; + let _ = update( + &mut state, + Message::BooksDiscovered { + generation, + discovery: shosai_core::library::ImportDiscovery { + candidates: vec![ + ImportCandidate { + path: PathBuf::from("one.epub"), + title: "one".to_string(), + group_key: "one".to_string(), + format: shosai_core::library::BookFormat::Epub, + file_size: 100, + content_hash: "one-hash".to_string(), + duplicate: None, + }, + ImportCandidate { + path: PathBuf::from("two.pdf"), + title: "two".to_string(), + group_key: "two".to_string(), + format: shosai_core::library::BookFormat::Pdf, + file_size: 200, + content_hash: "two-hash".to_string(), + duplicate: Some(ImportDuplicate::ExistingBook { + book_id: 7, + title: "Two".to_string(), + }), + }, + ], + failures: Vec::new(), + }, + }, + ); + assert!(!state.add_books_discovering); + assert!(state.add_books_progress.is_none()); + assert_eq!(state.staged_imports.len(), 2); + assert!(state.staged_imports[0].selected); + assert!(!state.staged_imports[1].selected); + assert_eq!(state.add_books_copy, Some(true)); drop(add_books_modal(&state)); + + let _ = update(&mut state, Message::ToggleStagedBook(1, true)); + let _ = update(&mut state, Message::SelectAllStagedBooks(false)); + assert!(!state.staged_imports[0].selected); + assert!(state.staged_imports[1].selected); + let _ = update(&mut state, Message::SelectAllStagedBooks(true)); + assert!(state.staged_imports[0].selected); + assert!(state.staged_imports[1].selected); + let _ = update(&mut state, Message::ChangeAddBooksStorage); - assert!(state.add_books_override); + assert_eq!(state.add_books_copy, None); + let _ = update(&mut state, Message::SelectAddBooksStorage(false)); + assert_eq!(state.add_books_copy, Some(false)); drop(add_books_modal(&state)); + let stale_generation = state.add_books_generation; let _ = update(&mut state, Message::ClearAddBooksSelection); assert!(state.add_books_source.is_none()); - assert!(!state.add_books_override); - + assert!(state.staged_imports.is_empty()); + assert_eq!(state.add_books_copy, None); let _ = update( &mut state, - Message::AddBookFolderSelected(Some(PathBuf::from("books"))), + Message::BooksDiscovered { + generation: stale_generation, + discovery: shosai_core::library::ImportDiscovery { + candidates: vec![ImportCandidate { + path: PathBuf::from("stale.epub"), + title: "stale".to_string(), + group_key: "stale".to_string(), + format: shosai_core::library::BookFormat::Epub, + file_size: 100, + content_hash: "stale-hash".to_string(), + duplicate: None, + }], + failures: Vec::new(), + }, + }, + ); + assert!(state.staged_imports.is_empty()); + + let generation = state.add_books_generation; + let discover = update( + &mut state, + Message::AddBookFolderSelected { + generation, + path: Some(PathBuf::from("books")), + }, ); + assert!(discover.units() > 0); assert!(matches!( state.add_books_source, Some(AddBooksSource::Folder(ref path)) if path == &PathBuf::from("books") )); + assert!(state.add_books_discovering); + let cancellation = state.add_books_cancellation.clone().unwrap(); let _ = update(&mut state, Message::CancelAddBooks); assert!(!state.add_books_open); assert!(state.add_books_source.is_none()); + assert!(!state.add_books_discovering); + assert!(state.add_books_progress.is_none()); + assert!(cancellation.is_cancelled()); + } + + #[tokio::test] + async fn batch_import_updates_progress_and_library_before_completion() { + let directory = tempfile::tempdir().unwrap(); + let store = ReadingStateStore::open_at_async(&directory.path().join("state.db")) + .await + .unwrap(); + let (mut state, _) = boot(); + state.library = Some(Library::new( + store.pool().clone(), + store.managed_books_dir(), + )); + state.library_loading = false; + state.adding_books = true; + state.book_import_total = 2; + state.pending_book_imports.push_back(( + 1, + ImportCandidate { + path: PathBuf::from("second.epub"), + title: "second".to_string(), + group_key: "second".to_string(), + format: shosai_core::library::BookFormat::Epub, + file_size: 100, + content_hash: "second-hash".to_string(), + duplicate: None, + }, + )); + + let task = update( + &mut state, + Message::BookAddedToBatch(ImportReport { + books: vec![test_book(42)], + failures: Vec::new(), + }), + ); + + assert!(task.units() > 0); + assert!(state.adding_books); + assert_eq!(state.book_import_completed, 1); + assert_eq!(state.library_activity_progress, 0.5); + assert_eq!(state.library_books.len(), 1); + assert_eq!(state.library_books[0].id, 42); + } + + #[tokio::test] + async fn managed_import_starts_only_four_preparations() { + let directory = tempfile::tempdir().unwrap(); + let store = ReadingStateStore::open_at_async(&directory.path().join("state.db")) + .await + .unwrap(); + let (mut state, _) = boot(); + state.library = Some(Library::new( + store.pool().clone(), + store.managed_books_dir(), + )); + state.add_books_open = true; + state.add_books_copy = Some(true); + state.staged_imports = (0..6) + .map(|index| StagedImport { + selected: true, + candidate: ImportCandidate { + path: PathBuf::from(format!("book-{index}.epub")), + title: format!("book-{index}"), + group_key: format!("book-{index}"), + format: shosai_core::library::BookFormat::Epub, + file_size: 100, + content_hash: format!("hash-{index}"), + duplicate: None, + }, + }) + .collect(); + + let task = update(&mut state, Message::AddSelectedBooks); + + assert_eq!(task.units(), MANAGED_IMPORT_PREPARATION_CONCURRENCY); + assert_eq!(state.book_import_preparing, 4); + assert_eq!(state.pending_book_imports.len(), 2); + assert!(state.prepared_book_imports.is_empty()); + assert!(!state.book_import_committing); + } + + #[tokio::test] + async fn managed_import_bounds_completed_preparations_behind_a_slow_first_book() { + let directory = tempfile::tempdir().unwrap(); + let store = ReadingStateStore::open_at_async(&directory.path().join("state.db")) + .await + .unwrap(); + let (mut state, _) = boot(); + state.library = Some(Library::new( + store.pool().clone(), + store.managed_books_dir(), + )); + state.add_books_open = true; + state.add_books_copy = Some(true); + state.staged_imports = (0..8) + .map(|index| StagedImport { + selected: true, + candidate: ImportCandidate { + path: PathBuf::from(format!("book-{index}.epub")), + title: format!("book-{index}"), + group_key: format!("book-{index}"), + format: shosai_core::library::BookFormat::Epub, + file_size: 100, + content_hash: format!("hash-{index}"), + duplicate: None, + }, + }) + .collect(); + let _ = update(&mut state, Message::AddSelectedBooks); + + for index in 1..4 { + let _ = update( + &mut state, + Message::ManagedBookPrepared { + index, + result: Err(ImportFailure { + path: PathBuf::from(format!("book-{index}.epub")), + error: "test failure".to_string(), + }), + }, + ); + } + + assert_eq!(state.pending_book_imports.len(), 4); + assert_eq!(state.book_import_preparing, 1); + assert_eq!(state.prepared_book_imports.len(), 3); + assert_eq!(state.library_activity_progress, 3.0 / 16.0); + } + + #[tokio::test] + async fn stale_book_picker_results_cannot_replace_a_newer_session() { + let directory = tempfile::tempdir().unwrap(); + let store = ReadingStateStore::open_at_async(&directory.path().join("state.db")) + .await + .unwrap(); + let (mut state, _) = boot(); + state.library = Some(Library::new( + store.pool().clone(), + store.managed_books_dir(), + )); + + let _ = update(&mut state, Message::OpenAddBooks); + let old_session = state.add_books_generation; + let _ = update(&mut state, Message::CancelAddBooks); + let _ = update(&mut state, Message::OpenAddBooks); + let _ = update( + &mut state, + Message::AddBookFilesSelected { + generation: old_session, + paths: vec![PathBuf::from("old.epub")], + }, + ); + assert!(state.add_books_source.is_none()); + + let _ = update(&mut state, Message::ChooseBookFiles); + let older_picker = state.add_books_generation; + let _ = update(&mut state, Message::ChooseBookFolder); + let newer_picker = state.add_books_generation; + let _ = update( + &mut state, + Message::AddBookFilesSelected { + generation: older_picker, + paths: vec![PathBuf::from("older.epub")], + }, + ); + assert!(state.add_books_source.is_none()); + let task = update( + &mut state, + Message::AddBookFolderSelected { + generation: newer_picker, + path: Some(PathBuf::from("newer")), + }, + ); + assert!(task.units() > 0); + assert!(matches!( + state.add_books_source, + Some(AddBooksSource::Folder(ref path)) if path == &PathBuf::from("newer") + )); + } + + #[test] + fn import_candidate_labels_distinguish_nested_and_individual_files() { + let directory = tempfile::tempdir().unwrap(); + let nested = directory.path().join("publisher").join("book.epub"); + std::fs::create_dir_all(nested.parent().unwrap()).unwrap(); + std::fs::write(&nested, b"book").unwrap(); + let canonical = nested.canonicalize().unwrap(); + + assert_eq!( + import_candidate_label( + &AddBooksSource::Folder(directory.path().to_path_buf()), + &canonical, + ), + PathBuf::from("publisher") + .join("book.epub") + .display() + .to_string() + ); + assert_eq!( + import_candidate_label(&AddBooksSource::Files(vec![nested]), &canonical), + canonical.display().to_string() + ); } #[tokio::test] @@ -7031,6 +7866,43 @@ mod tests { assert_eq!(state.library_activity_progress, 1.0); } + #[test] + fn discovery_activity_is_monotonic_and_reaches_completion() { + let snapshots = [ + ImportDiscoveryProgressSnapshot { + enumerating: true, + hashed_files: 1, + completed_files: 0, + total_files: 4, + }, + ImportDiscoveryProgressSnapshot { + enumerating: true, + hashed_files: 3, + completed_files: 0, + total_files: 20, + }, + ImportDiscoveryProgressSnapshot { + enumerating: false, + hashed_files: 20, + completed_files: 4, + total_files: 20, + }, + ImportDiscoveryProgressSnapshot { + enumerating: false, + hashed_files: 20, + completed_files: 20, + total_files: 20, + }, + ]; + let mut value = 0.0; + for snapshot in snapshots { + let next = discovery_progress_value(value, snapshot); + assert!(next >= value); + value = next; + } + assert_eq!(value, 1.0); + } + #[test] fn later_library_pages_do_not_introduce_continue_reading() { let (mut state, _) = boot(); diff --git a/crates/shosai-app/src/app/dispatch.rs b/crates/shosai-app/src/app/dispatch.rs index 7324a78..6107d5f 100644 --- a/crates/shosai-app/src/app/dispatch.rs +++ b/crates/shosai-app/src/app/dispatch.rs @@ -1,5 +1,173 @@ use super::*; +fn cancel_add_books_discovery(state: &mut State) { + if let Some(cancellation) = state.add_books_cancellation.take() { + cancellation.cancel(); + } + state.add_books_progress = None; + state.add_books_discovering = false; +} + +fn update_book_import_progress(state: &mut State) { + state.library_activity_progress = if state.book_import_copy { + (state.book_import_prepared + state.book_import_completed) as f32 + / (state.book_import_total.max(1) * 2) as f32 + } else { + state.book_import_completed as f32 / state.book_import_total.max(1) as f32 + }; +} + +fn record_book_import_report(state: &mut State, report: ImportReport) { + state.book_import_completed += 1; + update_book_import_progress(state); + for book in &report.books { + if book_matches_library_view(state, book) + && !state + .library_books + .iter() + .any(|existing| existing.id == book.id) + { + state.library_books.push(book.clone()); + } + } + state.book_import_report.books.extend(report.books); + state.book_import_report.failures.extend(report.failures); +} + +fn finish_book_import(state: &mut State) -> Task { + state.adding_books = false; + state.pending_book_imports.clear(); + state.prepared_book_imports.clear(); + state.book_import_preparing = 0; + state.book_import_next_commit = 0; + state.book_import_committing = false; + let report = std::mem::take(&mut state.book_import_report); + state.book_import_total = 0; + state.book_import_prepared = 0; + state.book_import_completed = 0; + let error = import_report_error(&report, &state.i18n); + let refresh = reset_library(state); + if let Some(error) = error { + state.library_error = Some(error); + } + refresh +} + +fn continue_book_import(state: &mut State) -> Task { + if !state.adding_books { + return Task::none(); + } + let Some(library) = state.library.clone() else { + return finish_book_import(state); + }; + + if !state.book_import_copy { + if state.book_import_committing { + return Task::none(); + } + let Some((_index, candidate)) = state.pending_book_imports.pop_front() else { + return finish_book_import(state); + }; + state.book_import_committing = true; + return Task::perform( + async move { library.link_discovered_files(&[candidate]).await }, + Message::BookAddedToBatch, + ); + } + + let mut tasks = Vec::new(); + if !state.book_import_committing { + while let Some(prepared) = state + .prepared_book_imports + .remove(&state.book_import_next_commit) + { + state.book_import_next_commit += 1; + match prepared { + Err(failure) => record_book_import_report( + state, + ImportReport { + books: Vec::new(), + failures: vec![failure], + }, + ), + Ok((path, prepared)) => { + state.book_import_committing = true; + let commit_library = library.clone(); + tasks.push(Task::perform( + async move { + match commit_library.commit_prepared_managed_file(&prepared).await { + Ok(book) => ImportReport { + books: vec![book], + failures: Vec::new(), + }, + Err(error) => ImportReport { + books: Vec::new(), + failures: vec![ImportFailure { + path, + error: format!("{error:#}"), + }], + }, + } + }, + Message::BookAddedToBatch, + )); + break; + } + } + } + } + + while state.book_import_preparing + + state.prepared_book_imports.len() + + usize::from(state.book_import_committing) + < MANAGED_IMPORT_PREPARATION_CONCURRENCY + { + let Some((index, candidate)) = state.pending_book_imports.pop_front() else { + break; + }; + let library = library.clone(); + let path = candidate.path.clone(); + state.book_import_preparing += 1; + tasks.push(Task::perform( + async move { + match library.prepare_discovered_managed_file(candidate).await { + Ok(prepared) => Ok((path, Arc::new(prepared))), + Err(error) => Err(ImportFailure { + path, + error: format!("{error:#}"), + }), + } + }, + move |result| Message::ManagedBookPrepared { index, result }, + )); + } + + if state.book_import_completed == state.book_import_total + && !state.book_import_committing + && state.book_import_preparing == 0 + { + tasks.push(finish_book_import(state)); + } + + Task::batch(tasks) +} + +fn book_matches_library_view(state: &State, book: &Book) -> bool { + if state + .library_filter + .is_some_and(|format| format != book.format) + { + return false; + } + let query = state.library_search.to_lowercase(); + query.is_empty() + || book.title.to_lowercase().contains(&query) + || book + .author + .as_ref() + .is_some_and(|author| author.to_lowercase().contains(&query)) +} + pub fn update(state: &mut State, message: Message) -> Task { match message { Message::Initialized(Ok(initialized)) => { @@ -447,24 +615,41 @@ pub fn update(state: &mut State, message: Message) -> Task { Message::OpenAddBooks => { if state.library.is_some() && !state.adding_books && !state.moving_library { + cancel_add_books_discovery(state); state.book_menu = None; state.pending_remove_book = None; state.add_books_source = None; - state.add_books_override = false; + state.add_books_generation = state.add_books_generation.wrapping_add(1); + state.staged_imports.clear(); + state.add_books_review_search.clear(); + state.add_books_review_rows.clear(); + state.add_books_review_offset = 0.0; + state.import_discovery_failures.clear(); + state.add_books_copy = None; state.add_books_open = true; } } Message::CancelAddBooks => { + cancel_add_books_discovery(state); state.add_books_open = false; state.add_books_source = None; - state.add_books_override = false; + state.add_books_generation = state.add_books_generation.wrapping_add(1); + state.staged_imports.clear(); + state.add_books_review_search.clear(); + state.add_books_review_rows.clear(); + state.add_books_review_offset = 0.0; + state.import_discovery_failures.clear(); + state.add_books_copy = None; } Message::ChooseBookFiles => { if !state.add_books_open || state.library.is_none() { return Task::none(); } + cancel_add_books_discovery(state); + state.add_books_generation = state.add_books_generation.wrapping_add(1); + let generation = state.add_books_generation; let ebooks = state.i18n.text("ebooks"); let choose_files = state.i18n.text("choose-book-files"); return Task::perform( @@ -479,7 +664,7 @@ pub fn update(state: &mut State, message: Message) -> Task { .map(|file| file.path().to_path_buf()) .collect() }, - Message::AddBookFilesSelected, + move |paths| Message::AddBookFilesSelected { generation, paths }, ); } @@ -487,6 +672,9 @@ pub fn update(state: &mut State, message: Message) -> Task { if !state.add_books_open || state.library.is_none() { return Task::none(); } + cancel_add_books_discovery(state); + state.add_books_generation = state.add_books_generation.wrapping_add(1); + let generation = state.add_books_generation; let choose_folder = state.i18n.text("choose-book-folder"); return Task::perform( async move { @@ -496,80 +684,222 @@ pub fn update(state: &mut State, message: Message) -> Task { .await .map(|folder| folder.path().to_path_buf()) }, - Message::AddBookFolderSelected, + move |path| Message::AddBookFolderSelected { generation, path }, ); } - Message::AddBookFilesSelected(paths) => { - if state.add_books_open && !paths.is_empty() { - state.add_books_source = Some(AddBooksSource::Files(paths)); + Message::AddBookFilesSelected { generation, paths } => { + let Some(lib) = state.library.clone() else { + return Task::none(); + }; + if !state.add_books_open || generation != state.add_books_generation || paths.is_empty() + { + return Task::none(); } + state.add_books_source = Some(AddBooksSource::Files(paths.clone())); + state.add_books_discovering = true; + state.library_activity_progress = 0.0; + state.staged_imports.clear(); + state.import_discovery_failures.clear(); + let cancellation = ImportCancellation::default(); + let progress = ImportDiscoveryProgress::default(); + state.add_books_cancellation = Some(cancellation.clone()); + state.add_books_progress = Some(progress.clone()); + return Task::perform( + async move { + lib.discover_files_with_progress(paths, cancellation, progress) + .await + }, + move |discovery| Message::BooksDiscovered { + generation, + discovery, + }, + ); } - Message::AddBookFolderSelected(path) => { + Message::AddBookFolderSelected { generation, path } => { + let (Some(lib), Some(path)) = (state.library.clone(), path) else { + return Task::none(); + }; + if !state.add_books_open || generation != state.add_books_generation { + return Task::none(); + } + state.add_books_source = Some(AddBooksSource::Folder(path.clone())); + state.add_books_discovering = true; + state.library_activity_progress = 0.0; + state.staged_imports.clear(); + state.import_discovery_failures.clear(); + let cancellation = ImportCancellation::default(); + let progress = ImportDiscoveryProgress::default(); + state.add_books_cancellation = Some(cancellation.clone()); + state.add_books_progress = Some(progress.clone()); + return Task::perform( + async move { + lib.discover_directory_with_progress(path, cancellation, progress) + .await + }, + move |discovery| Message::BooksDiscovered { + generation, + discovery, + }, + ); + } + + Message::BooksDiscovered { + generation, + discovery, + } => { + if !state.add_books_open || generation != state.add_books_generation { + return Task::none(); + } + state.add_books_discovering = false; + state.add_books_cancellation = None; + state.add_books_progress = None; + state.library_activity_progress = 1.0; + state.staged_imports = discovery + .candidates + .into_iter() + .map(|candidate| StagedImport { + selected: candidate.duplicate.is_none(), + candidate, + }) + .collect(); + state.add_books_review_search.clear(); + rebuild_add_books_review_rows(state); + state.import_discovery_failures = discovery.failures; + state.add_books_copy = match state.add_book_behavior { + AddBookBehavior::Ask => None, + AddBookBehavior::Copy => Some(true), + AddBookBehavior::CurrentLocation => Some(false), + }; + } + + Message::AddBooksReviewSearchChanged(query) => { + if state.add_books_open && !state.add_books_discovering { + state.add_books_review_search = query; + rebuild_add_books_review_rows(state); + return iced::widget::operation::snap_to( + WidgetId::new("add-books-review-scroll"), + iced::widget::operation::RelativeOffset::START, + ); + } + } + + Message::AddBooksReviewScrolled { + generation, + revision, + offset, + viewport_height, + } => { if state.add_books_open - && let Some(path) = path + && !state.add_books_discovering + && generation == state.add_books_generation + && revision == state.add_books_review_revision { - state.add_books_source = Some(AddBooksSource::Folder(path)); + state.add_books_review_offset = offset.max(0.0); + state.add_books_review_viewport_height = viewport_height.max(1.0); + } + } + + Message::ToggleStagedBook(index, selected) => { + if state.add_books_open + && let Some(staged) = state.staged_imports.get_mut(index) + { + staged.selected = selected; + } + } + + Message::SelectAllStagedBooks(selected) => { + if state.add_books_open { + for staged in &mut state.staged_imports { + if staged.candidate.duplicate.is_none() { + staged.selected = selected; + } + } + } + } + + Message::SelectAddBooksStorage(copy) => { + if state.add_books_open && state.add_books_source.is_some() { + state.add_books_copy = Some(copy); } } Message::ClearAddBooksSelection => { if state.add_books_open { + cancel_add_books_discovery(state); state.add_books_source = None; - state.add_books_override = false; + state.add_books_generation = state.add_books_generation.wrapping_add(1); + state.staged_imports.clear(); + state.add_books_review_search.clear(); + state.add_books_review_rows.clear(); + state.add_books_review_offset = 0.0; + state.import_discovery_failures.clear(); + state.add_books_copy = None; } } Message::ChangeAddBooksStorage => { if state.add_books_open && state.add_books_source.is_some() { - state.add_books_override = true; + state.add_books_copy = None; } } - Message::AddSelectedBooks { copy } => { + Message::AddSelectedBooks => { if state.adding_books { return Task::none(); } - let (Some(lib), Some(source)) = (state.library.clone(), state.add_books_source.take()) - else { + let Some(copy) = state.add_books_copy else { return Task::none(); }; + if state.library.is_none() { + return Task::none(); + } + let candidates: Vec<_> = state + .staged_imports + .iter() + .filter(|staged| staged.selected) + .map(|staged| staged.candidate.clone()) + .collect(); + if candidates.is_empty() { + return Task::none(); + } state.add_books_open = false; + state.add_books_source = None; + state.staged_imports.clear(); + state.add_books_review_search.clear(); + state.add_books_review_rows.clear(); + state.add_books_review_offset = 0.0; + state.import_discovery_failures.clear(); + state.add_books_copy = None; state.adding_books = true; + state.pending_book_imports = candidates.into_iter().enumerate().collect(); + state.prepared_book_imports.clear(); + state.book_import_preparing = 0; + state.book_import_next_commit = 0; + state.book_import_committing = false; + state.book_import_copy = copy; + state.book_import_prepared = 0; + state.book_import_completed = 0; + state.book_import_total = state.pending_book_imports.len(); + state.book_import_report = ImportReport::default(); state.library_activity_progress = 0.0; state.library_error = None; - return Task::perform( - async move { - match source { - AddBooksSource::Files(paths) => { - if copy { - lib.import_files(&paths).await - } else { - lib.link_files(&paths).await - } - } - AddBooksSource::Folder(path) => { - if copy { - lib.import_directory(&path).await - } else { - lib.link_directory(&path).await - } - } - } - }, - Message::BooksAdded, - ); + return continue_book_import(state); } - Message::BooksAdded(report) => { - state.adding_books = false; - let error = import_report_error(&report, &state.i18n); - let refresh = reset_library(state); - if let Some(error) = error { - state.library_error = Some(error); - } - return refresh; + Message::ManagedBookPrepared { index, result } => { + state.book_import_preparing = state.book_import_preparing.saturating_sub(1); + state.book_import_prepared += 1; + update_book_import_progress(state); + state.prepared_book_imports.insert(index, result); + return continue_book_import(state); + } + + Message::BookAddedToBatch(report) => { + state.book_import_committing = false; + record_book_import_report(state, report); + return continue_book_import(state); } Message::OpenLibraryBook(book_id, file_path) => { @@ -765,7 +1095,13 @@ pub fn update(state: &mut State, message: Message) -> Task { } Message::LibraryActivityTick => { - if library_activity_active(state) { + if state.add_books_discovering { + if let Some(progress) = &state.add_books_progress { + let progress = progress.snapshot(); + state.library_activity_progress = + discovery_progress_value(state.library_activity_progress, progress); + } + } else if !state.adding_books && library_activity_active(state) { state.library_activity_progress = (state.library_activity_progress + LIBRARY_ACTIVITY_STEP).min(1.0); } @@ -878,7 +1214,8 @@ pub fn update(state: &mut State, message: Message) -> Task { let (Some(parent), Some(library)) = (parent, state.library.clone()) else { return Task::none(); }; - let destination = parent.join("Shosai"); + let destination = + parent.join(shosai_core::reading_state::managed_library_folder_name()); if library.managed_dir() == destination { return Task::none(); } @@ -941,6 +1278,10 @@ pub fn update(state: &mut State, message: Message) -> Task { let _ = wait.await; } } + shosai_core::reading_state::prepare_managed_library_directory( + &relocation_destination, + ) + .map_err(|error| format!("{error:#}"))?; library .relocate_managed_books(&relocation_destination) .await diff --git a/crates/shosai-app/src/app/message.rs b/crates/shosai-app/src/app/message.rs index 36b1919..5deae43 100644 --- a/crates/shosai-app/src/app/message.rs +++ b/crates/shosai-app/src/app/message.rs @@ -4,7 +4,9 @@ use std::sync::Arc; use iced::{keyboard, window}; use shosai_core::bookmarks::Bookmark; use shosai_core::document::RenderedPage; -use shosai_core::library::{Book, BookPage, ImportReport}; +use shosai_core::library::{ + Book, BookPage, ImportDiscovery, ImportFailure, ImportReport, PreparedManagedImport, +}; use shosai_core::search::SearchMatch; use super::{ContinuousRequest, EpubLayoutKey, EpubPage, InitializedState, PageCacheKey}; @@ -89,14 +91,36 @@ pub enum Message { CancelAddBooks, ChooseBookFiles, ChooseBookFolder, - AddBookFilesSelected(Vec), - AddBookFolderSelected(Option), + AddBookFilesSelected { + generation: u64, + paths: Vec, + }, + AddBookFolderSelected { + generation: u64, + path: Option, + }, + BooksDiscovered { + generation: u64, + discovery: ImportDiscovery, + }, + AddBooksReviewSearchChanged(String), + AddBooksReviewScrolled { + generation: u64, + revision: u64, + offset: f32, + viewport_height: f32, + }, + ToggleStagedBook(usize, bool), + SelectAllStagedBooks(bool), + SelectAddBooksStorage(bool), ClearAddBooksSelection, ChangeAddBooksStorage, - AddSelectedBooks { - copy: bool, + AddSelectedBooks, + ManagedBookPrepared { + index: usize, + result: Result<(PathBuf, Arc), ImportFailure>, }, - BooksAdded(ImportReport), + BookAddedToBatch(ImportReport), OpenLibraryBook(i64, String), LocateBook(i64), RelinkBookSelected(i64, Option), diff --git a/crates/shosai-app/src/main.rs b/crates/shosai-app/src/main.rs index 9d1b26f..aa3c841 100644 --- a/crates/shosai-app/src/main.rs +++ b/crates/shosai-app/src/main.rs @@ -6,14 +6,18 @@ mod theme; mod typography; mod widgets; -const APPLICATION_ICON: &[u8] = if option_env!("SHOSAI_DEV_BUILD").is_some() { - include_bytes!("../../../assets/shosai-dev-icon.png") -} else { - include_bytes!("../../../assets/shosai-icon.png") -}; +fn application_icon() -> &'static [u8] { + if shosai_core::reading_state::is_development_profile() { + include_bytes!("../../../assets/shosai-dev-icon.png") + } else { + include_bytes!("../../../assets/shosai-icon.png") + } +} fn window_icon() -> Option { - let icon = image::load_from_memory(APPLICATION_ICON).ok()?.into_rgba8(); + let icon = image::load_from_memory(application_icon()) + .ok()? + .into_rgba8(); let (width, height) = icon.dimensions(); iced::window::icon::from_rgba(icon.into_raw(), width, height).ok() } @@ -27,7 +31,7 @@ fn set_macos_application_icon() { let Some(main_thread) = MainThreadMarker::new() else { return; }; - let data = NSData::with_bytes(APPLICATION_ICON); + let data = NSData::with_bytes(application_icon()); let Some(icon) = NSImage::initWithData(NSImage::alloc(), &data) else { return; }; diff --git a/crates/shosai-app/src/theme.rs b/crates/shosai-app/src/theme.rs index efdbc27..93c4fd1 100644 --- a/crates/shosai-app/src/theme.rs +++ b/crates/shosai-app/src/theme.rs @@ -342,12 +342,6 @@ pub fn bookmark_link(_theme: &Theme, status: button::Status) -> button::Style { } } -pub fn activity_bar(active: bool) -> impl Fn(&Theme) -> container::Style { - move |_theme| { - container::Style::default().background(if active { ACCENT } else { Color::TRANSPARENT }) - } -} - pub fn skeleton(_theme: &Theme) -> container::Style { container::Style::default() .background(SURFACE_MUTED) diff --git a/crates/shosai-app/src/widgets.rs b/crates/shosai-app/src/widgets.rs index b6a84ea..f6d8f0c 100644 --- a/crates/shosai-app/src/widgets.rs +++ b/crates/shosai-app/src/widgets.rs @@ -1,4 +1,4 @@ -use iced::widget::{Button, ProgressBar, button, container, progress_bar, row, text}; +use iced::widget::{Button, ProgressBar, button, progress_bar, text}; use iced::{Element, Font, Length}; use crate::theme; @@ -38,26 +38,6 @@ pub fn navigation_button<'a, Message: Clone + 'a>( .style(theme::navigation_button(selected)) } -pub fn activity_bar<'a, Message: 'a>(active: bool, progress: f32) -> Element<'a, Message> { - let leading = (progress.clamp(0.0, 1.0) * 800.0).round() as u16; - let trailing = 800 - leading; - let mut line = row![].height(2); - if leading > 0 { - line = line.push(iced::widget::Space::new().width(Length::FillPortion(leading))); - } - line = line.push( - container(iced::widget::Space::new()) - .width(Length::FillPortion(200)) - .height(2) - .style(theme::activity_bar(active)), - ); - if trailing > 0 { - line = line.push(iced::widget::Space::new().width(Length::FillPortion(trailing))); - } - - container(line).width(Length::Fill).height(2).into() -} - pub fn book_button<'a, Message: Clone + 'a>( content: impl Into>, message: Option, diff --git a/crates/shosai-core/Cargo.toml b/crates/shosai-core/Cargo.toml index ce56aa6..ccb5a26 100644 --- a/crates/shosai-core/Cargo.toml +++ b/crates/shosai-core/Cargo.toml @@ -11,7 +11,7 @@ bytes.workspace = true percent-encoding.workspace = true pdfium-render = { version = "0.8", features = ["thread_safe", "sync", "image"] } sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } -tokio = { version = "1", features = ["rt"] } +tokio = { version = "1", features = ["rt", "macros", "sync"] } zip = { version = "8.4.0", default-features = false, features = ["deflate"] } flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"] } roxmltree = "0.21.1" @@ -26,6 +26,7 @@ wuff = { version = "=0.2.8", default-features = false } image = { version = "0.25.10", default-features = false, features = ["default-formats"] } natord = "1.0.9" unicode-casefold = "0.2.0" +unicode-normalization = "0.1.25" sha2 = "0.10.9" [dev-dependencies] diff --git a/crates/shosai-core/src/library.rs b/crates/shosai-core/src/library.rs index 0c0bb55..22fcbf2 100644 --- a/crates/shosai-core/src/library.rs +++ b/crates/shosai-core/src/library.rs @@ -2,8 +2,10 @@ //! //! Uses the same SQLite database as the reading state store. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use anyhow::{Context, Result, bail}; use sha2::{Digest, Sha256}; @@ -11,6 +13,8 @@ use sqlx::QueryBuilder; use sqlx::Row; use sqlx::Transaction; use sqlx::sqlite::{Sqlite, SqlitePool}; +use unicode_casefold::UnicodeCaseFold; +use unicode_normalization::UnicodeNormalization; use crate::cbz::CbzDoc; use crate::document::Document; @@ -18,6 +22,7 @@ use crate::epub::EpubDoc; use crate::pdf::PdfDoc; pub const MANAGED_LIBRARY_DIR_PREFERENCE: &str = "library.managed_books_dir"; +const DISCOVERY_HASH_CONCURRENCY: usize = 4; /// Supported book format. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -110,6 +115,99 @@ pub struct ImportFailure { pub error: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImportDuplicate { + ExistingBook { book_id: i64, title: String }, + SelectedFile { path: PathBuf }, +} + +#[derive(Debug, Clone)] +pub struct ImportCandidate { + pub path: PathBuf, + pub title: String, + pub group_key: String, + pub format: BookFormat, + pub file_size: u64, + pub content_hash: String, + pub duplicate: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct ImportDiscovery { + pub candidates: Vec, + pub failures: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct ImportCancellation(Arc); + +impl ImportCancellation { + pub fn cancel(&self) { + self.0.store(true, Ordering::Release); + } + + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Acquire) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ImportDiscoveryProgressSnapshot { + pub enumerating: bool, + pub hashed_files: u64, + pub completed_files: u64, + pub total_files: u64, +} + +#[derive(Debug, Clone)] +pub struct ImportDiscoveryProgress(Arc); + +#[derive(Debug)] +struct ImportDiscoveryProgressInner { + enumerating: AtomicBool, + hashed_files: AtomicU64, + completed_files: AtomicU64, + total_files: AtomicU64, +} + +impl Default for ImportDiscoveryProgress { + fn default() -> Self { + Self(Arc::new(ImportDiscoveryProgressInner { + enumerating: AtomicBool::new(true), + hashed_files: AtomicU64::new(0), + completed_files: AtomicU64::new(0), + total_files: AtomicU64::new(0), + })) + } +} + +impl ImportDiscoveryProgress { + pub fn snapshot(&self) -> ImportDiscoveryProgressSnapshot { + ImportDiscoveryProgressSnapshot { + enumerating: self.0.enumerating.load(Ordering::Acquire), + hashed_files: self.0.hashed_files.load(Ordering::Acquire), + completed_files: self.0.completed_files.load(Ordering::Acquire), + total_files: self.0.total_files.load(Ordering::Acquire), + } + } + + fn found_file(&self) { + self.0.total_files.fetch_add(1, Ordering::AcqRel); + } + + fn completed_file(&self) { + self.0.completed_files.fetch_add(1, Ordering::AcqRel); + } + + fn hashed_file(&self) { + self.0.hashed_files.fetch_add(1, Ordering::AcqRel); + } + + fn finish_enumerating(&self) { + self.0.enumerating.store(false, Ordering::Release); + } +} + #[derive(Debug, Clone, Default)] pub struct ImportReport { pub books: Vec, @@ -141,11 +239,13 @@ pub struct ManagedPathChange { pub new_path: PathBuf, } +#[derive(Debug)] struct FileFingerprint { hash: String, size: u64, } +#[derive(Debug)] struct BookInspection { title: String, author: Option, @@ -153,6 +253,16 @@ struct BookInspection { fingerprint: FileFingerprint, } +/// A verified private copy that is ready to be published and recorded in the library. +#[derive(Debug)] +pub struct PreparedManagedImport { + source_str: String, + extension: String, + format: BookFormat, + staged: ManagedStage, + inspection: BookInspection, +} + /// Library backed by SQLite. #[derive(Debug, Clone)] pub struct Library { @@ -322,11 +432,31 @@ impl Library { /// Extracts metadata and cover image from the file. If the file /// already exists in the library, returns its existing book entry. pub async fn import_file(&self, path: &Path) -> Result { + self.import_file_with_hash(path, None).await + } + + async fn import_file_with_hash( + &self, + path: &Path, + expected_hash: Option<&str>, + ) -> Result { // Normalize paths so lookups and progress updates stay consistent. let path = canonical_path(path); let path_str = path.to_string_lossy().to_string(); - // Check if already imported. + if let Some(expected_hash) = expected_hash { + let fingerprint_path = path.clone(); + let actual_hash = tokio::task::spawn_blocking(move || { + file_fingerprint(&fingerprint_path).map(|fingerprint| fingerprint.hash) + }) + .await + .context("book verification task failed")??; + if actual_hash != expected_hash { + bail!("file changed after review: {}", path.display()); + } + } + + // Check if already imported after validating a reviewed file. if let Some(book) = self.get_by_path(&path_str).await? { return Ok(book); } @@ -342,8 +472,14 @@ impl Library { // Parsing documents, decoding images, and rendering PDF covers are CPU-heavy. Keep that // work away from the async executor so imports do not stall the application UI. let metadata_path = path.clone(); + let expected_hash = expected_hash.map(str::to_owned); let inspection = tokio::task::spawn_blocking(move || { - inspect_book(&metadata_path, &metadata_path, format) + inspect_book( + &metadata_path, + &metadata_path, + format, + expected_hash.as_deref(), + ) }) .await .context("metadata extraction task failed")??; @@ -376,6 +512,32 @@ impl Library { /// Copy a book into Shosai's private data directory and add it to the library. pub async fn import_managed_file(&self, source: &Path) -> Result { + self.import_managed_file_with_hash(source, None).await + } + + async fn import_managed_file_with_hash( + &self, + source: &Path, + expected_hash: Option<&str>, + ) -> Result { + let prepared = self.prepare_managed_file(source, expected_hash).await?; + self.commit_prepared_managed_file(&prepared).await + } + + /// Prepare a discovered book for managed import without mutating the library database. + pub async fn prepare_discovered_managed_file( + &self, + candidate: ImportCandidate, + ) -> Result { + self.prepare_managed_file(&candidate.path, Some(&candidate.content_hash)) + .await + } + + async fn prepare_managed_file( + &self, + source: &Path, + expected_hash: Option<&str>, + ) -> Result { let source = canonical_path(source); let source_str = source.to_string_lossy().to_string(); let ext = source @@ -392,15 +554,43 @@ impl Library { .context("managed book staging task failed")??; let inspection_path = staged.path.clone(); let title_path = source.clone(); + let expected_hash = expected_hash.map(str::to_owned); let inspection = tokio::task::spawn_blocking(move || { - inspect_book(&inspection_path, &title_path, format) + inspect_book( + &inspection_path, + &title_path, + format, + expected_hash.as_deref(), + ) }) .await .context("book inspection task failed")??; + Ok(PreparedManagedImport { + source_str, + extension: ext, + format, + staged, + inspection, + }) + } + + /// Publish one prepared private copy and update the library database. + pub async fn commit_prepared_managed_file( + &self, + prepared: &PreparedManagedImport, + ) -> Result { + let PreparedManagedImport { + source_str, + extension, + format, + staged, + inspection, + } = prepared; + let destination = self .managed_dir - .join(format!("{}.{ext}", inspection.fingerprint.hash)); + .join(format!("{}.{extension}", inspection.fingerprint.hash)); let publish_stage = staged.path.clone(); let copy_destination = destination.clone(); let expected_hash = inspection.fingerprint.hash.clone(); @@ -419,13 +609,13 @@ impl Library { return Ok(existing.clone()); } - if let Some(existing) = self.get_by_path(&source_str).await?.or(existing_hash) { + if let Some(existing) = self.get_by_path(source_str).await?.or(existing_hash) { self.update_location( existing.id, &existing.file_path, &destination_str, StorageKind::Managed, - Some(&source_str), + Some(source_str), &inspection.fingerprint, ) .await?; @@ -446,7 +636,7 @@ impl Library { .bind(format.as_str()) .bind(&destination_str) .bind(&inspection.cover) - .bind(&source_str) + .bind(source_str) .bind(&inspection.fingerprint.hash) .bind(inspection.fingerprint.size as i64) .execute(&self.pool) @@ -461,7 +651,7 @@ impl Library { .get_by_path(&destination_str) .await? .context("managed book not found after insert")?; - self.attach_identity(book.id, &source_str, &destination_str) + self.attach_identity(book.id, source_str, &destination_str) .await?; Ok(book) } @@ -515,6 +705,271 @@ impl Library { self.add_files(paths, false).await } + /// Copy candidates after verifying that they still match their discovery fingerprints. + pub async fn import_discovered_files(&self, candidates: &[ImportCandidate]) -> ImportReport { + self.add_discovered_files(candidates, true).await + } + + /// Link candidates after verifying that they still match their discovery fingerprints. + pub async fn link_discovered_files(&self, candidates: &[ImportCandidate]) -> ImportReport { + self.add_discovered_files(candidates, false).await + } + + async fn add_discovered_files( + &self, + candidates: &[ImportCandidate], + managed: bool, + ) -> ImportReport { + let mut report = ImportReport::default(); + for candidate in candidates { + let result = if managed { + self.import_managed_file_with_hash(&candidate.path, Some(&candidate.content_hash)) + .await + } else { + self.import_file_with_hash(&candidate.path, Some(&candidate.content_hash)) + .await + }; + report.record(candidate.path.clone(), result); + } + report + } + + /// Inspect selected files before importing anything. + pub async fn discover_files(&self, paths: &[PathBuf]) -> ImportDiscovery { + self.discover( + paths.to_vec(), + false, + ImportCancellation::default(), + ImportDiscoveryProgress::default(), + ) + .await + } + + /// Recursively inspect a directory before importing anything. + pub async fn discover_directory(&self, dir: &Path) -> ImportDiscovery { + self.discover( + vec![dir.to_path_buf()], + true, + ImportCancellation::default(), + ImportDiscoveryProgress::default(), + ) + .await + } + + pub async fn discover_files_cancellable( + &self, + paths: Vec, + cancellation: ImportCancellation, + ) -> ImportDiscovery { + self.discover( + paths, + false, + cancellation, + ImportDiscoveryProgress::default(), + ) + .await + } + + pub async fn discover_directory_cancellable( + &self, + dir: PathBuf, + cancellation: ImportCancellation, + ) -> ImportDiscovery { + self.discover( + vec![dir], + true, + cancellation, + ImportDiscoveryProgress::default(), + ) + .await + } + + pub async fn discover_files_with_progress( + &self, + paths: Vec, + cancellation: ImportCancellation, + progress: ImportDiscoveryProgress, + ) -> ImportDiscovery { + self.discover(paths, false, cancellation, progress).await + } + + pub async fn discover_directory_with_progress( + &self, + dir: PathBuf, + cancellation: ImportCancellation, + progress: ImportDiscoveryProgress, + ) -> ImportDiscovery { + self.discover(vec![dir], true, cancellation, progress).await + } + + async fn discover( + &self, + roots: Vec, + recursive: bool, + cancellation: ImportCancellation, + progress: ImportDiscoveryProgress, + ) -> ImportDiscovery { + let (scan_sender, mut scan_receiver) = tokio::sync::mpsc::channel(16); + let scan_cancellation = cancellation.clone(); + let scan_progress = progress.clone(); + let scan_task = tokio::task::spawn_blocking(move || { + scan_import_candidates( + roots, + recursive, + &scan_cancellation, + &scan_progress, + &scan_sender, + ); + }); + let mut fingerprint_tasks = tokio::task::JoinSet::new(); + let mut fingerprinted = Vec::new(); + let mut discovery = ImportDiscovery::default(); + let mut scanning = true; + + while scanning || !fingerprint_tasks.is_empty() { + if cancellation.is_cancelled() { + fingerprint_tasks.abort_all(); + break; + } + + if fingerprint_tasks.len() >= DISCOVERY_HASH_CONCURRENCY || !scanning { + if let Some(result) = fingerprint_tasks.join_next().await { + collect_fingerprint_result( + result, + &progress, + &mut fingerprinted, + &mut discovery.failures, + ); + } + continue; + } + + if fingerprint_tasks.is_empty() { + match scan_receiver.recv().await { + Some(ScannedImport::Candidate(candidate)) => { + spawn_candidate_fingerprint( + &mut fingerprint_tasks, + candidate, + cancellation.clone(), + ); + } + Some(ScannedImport::Failure(failure)) => discovery.failures.push(failure), + None => scanning = false, + } + continue; + } + + tokio::select! { + item = scan_receiver.recv() => match item { + Some(ScannedImport::Candidate(candidate)) => { + spawn_candidate_fingerprint( + &mut fingerprint_tasks, + candidate, + cancellation.clone(), + ); + } + Some(ScannedImport::Failure(failure)) => discovery.failures.push(failure), + None => scanning = false, + }, + result = fingerprint_tasks.join_next() => { + if let Some(result) = result { + collect_fingerprint_result( + result, + &progress, + &mut fingerprinted, + &mut discovery.failures, + ); + } + } + } + } + + if !cancellation.is_cancelled() + && let Err(error) = scan_task.await + { + discovery.failures.push(ImportFailure { + path: PathBuf::new(), + error: format!("book discovery task failed: {error}"), + }); + } + + fingerprinted.sort_by(|left, right| left.0.path.cmp(&right.0.path)); + discovery.candidates.reserve(fingerprinted.len()); + let mut selected_hashes = HashMap::::new(); + + for (candidate, fingerprint) in fingerprinted { + if cancellation.is_cancelled() { + break; + } + let fingerprint = match fingerprint { + Ok(fingerprint) => fingerprint, + Err(error) => { + discovery.failures.push(ImportFailure { + path: candidate.path, + error, + }); + progress.completed_file(); + continue; + } + }; + let path_str = candidate.path.to_string_lossy(); + let existing = match self.get_by_path(path_str.as_ref()).await { + Ok(Some(book)) => Ok(Some(book)), + Ok(None) => self.get_by_hash(&fingerprint.hash).await, + Err(error) => Err(error), + }; + let existing = match existing { + Ok(existing) => existing, + Err(error) => { + discovery.failures.push(ImportFailure { + path: candidate.path, + error: format!("failed to check the library: {error:#}"), + }); + progress.completed_file(); + continue; + } + }; + let duplicate = existing.map_or_else( + || { + selected_hashes + .get(&fingerprint.hash) + .cloned() + .map(|path| ImportDuplicate::SelectedFile { path }) + }, + |book| { + Some(ImportDuplicate::ExistingBook { + book_id: book.id, + title: book.title, + }) + }, + ); + selected_hashes + .entry(fingerprint.hash.clone()) + .or_insert_with(|| candidate.path.clone()); + discovery.candidates.push(ImportCandidate { + title: filename_title(&candidate.path), + group_key: import_group_key(&candidate.path), + path: candidate.path, + format: candidate.format, + file_size: fingerprint.size, + content_hash: fingerprint.hash, + duplicate, + }); + progress.completed_file(); + } + discovery.candidates.sort_by(|left, right| { + left.group_key + .cmp(&right.group_key) + .then_with(|| left.path.cmp(&right.path)) + }); + discovery.failures.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.error.cmp(&right.error)) + }); + discovery + } + async fn add_files(&self, paths: &[PathBuf], managed: bool) -> ImportReport { let mut report = ImportReport::default(); for path in paths { @@ -844,7 +1299,8 @@ impl Library { let row = sqlx::query( "SELECT id, title, author, format, file_path, storage_kind, original_path, content_hash, file_size, cover_blob, progress, date_added, last_read - FROM books WHERE content_hash = ? ORDER BY storage_kind = 'managed' DESC LIMIT 1", + FROM books WHERE content_hash = ? + ORDER BY storage_kind = 'managed' DESC, id ASC LIMIT 1", ) .bind(content_hash) .fetch_optional(&self.pool) @@ -882,7 +1338,9 @@ impl Library { } async fn attach_identity(&self, book_id: i64, old_path: &str, new_path: &str) -> Result<()> { - let mut transaction = self.pool.begin().await?; + // Reconciliation reads before writing, so reserve SQLite's writer slot up front rather + // than failing a deferred transaction's lock upgrade during concurrent imports. + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; reconcile_identity(&mut transaction, book_id, old_path, new_path).await?; transaction.commit().await?; Ok(()) @@ -1065,26 +1523,59 @@ fn extract_metadata_and_cover( } } -fn inspect_book(path: &Path, title_path: &Path, format: BookFormat) -> Result { +fn inspect_book( + path: &Path, + title_path: &Path, + format: BookFormat, + expected_hash: Option<&str>, +) -> Result { + if let Some(expected_hash) = expected_hash + && file_fingerprint(path)?.hash != expected_hash + { + bail!("file changed after review: {}", title_path.display()); + } let (title, author, cover) = extract_metadata_and_cover(path, title_path, format)?; + let fingerprint = file_fingerprint(path)?; + if expected_hash.is_some_and(|expected| expected != fingerprint.hash) { + bail!("file changed after review: {}", title_path.display()); + } Ok(BookInspection { title, author, cover, - fingerprint: file_fingerprint(path)?, + fingerprint, }) } fn file_fingerprint(path: &Path) -> Result { - use std::io::Read; + file_fingerprint_cancellable(path, None) +} - let mut file = +fn file_fingerprint_cancellable( + path: &Path, + cancellation: Option<&ImportCancellation>, +) -> Result { + if cancellation.is_some_and(ImportCancellation::is_cancelled) { + bail!("discovery cancelled"); + } + let file = std::fs::File::open(path).with_context(|| format!("failed to read {}", path.display()))?; let file_size = file.metadata()?.len(); + fingerprint_reader(file, file_size, cancellation) +} + +fn fingerprint_reader( + mut reader: impl std::io::Read, + file_size: u64, + cancellation: Option<&ImportCancellation>, +) -> Result { let mut hasher = Sha256::new(); let mut buffer = [0_u8; 64 * 1024]; loop { - let read = file.read(&mut buffer)?; + if cancellation.is_some_and(ImportCancellation::is_cancelled) { + bail!("discovery cancelled"); + } + let read = reader.read(&mut buffer)?; if read == 0 { break; } @@ -1096,6 +1587,203 @@ fn file_fingerprint(path: &Path) -> Result { }) } +struct PendingImportCandidate { + path: PathBuf, + format: BookFormat, +} + +enum ScannedImport { + Candidate(PendingImportCandidate), + Failure(ImportFailure), +} + +enum ScanCursor { + Path(PathBuf), + Directory(PathBuf, std::fs::ReadDir), +} + +fn spawn_candidate_fingerprint( + tasks: &mut tokio::task::JoinSet<(PendingImportCandidate, Result)>, + candidate: PendingImportCandidate, + cancellation: ImportCancellation, +) { + let path = candidate.path.clone(); + tasks.spawn_blocking(move || { + let fingerprint = file_fingerprint_cancellable(&path, Some(&cancellation)); + (candidate, fingerprint) + }); +} + +fn collect_fingerprint_result( + result: std::result::Result< + (PendingImportCandidate, Result), + tokio::task::JoinError, + >, + progress: &ImportDiscoveryProgress, + fingerprinted: &mut Vec<( + PendingImportCandidate, + std::result::Result, + )>, + failures: &mut Vec, +) { + match result { + Ok((candidate, result)) => { + progress.hashed_file(); + fingerprinted.push((candidate, result.map_err(|error| format!("{error:#}")))); + } + Err(error) => { + progress.hashed_file(); + progress.completed_file(); + failures.push(ImportFailure { + path: PathBuf::new(), + error: format!("book fingerprint task failed: {error}"), + }); + } + } +} + +fn scan_import_candidates( + roots: Vec, + recursive: bool, + cancellation: &ImportCancellation, + progress: &ImportDiscoveryProgress, + sender: &tokio::sync::mpsc::Sender, +) { + let mut pending = roots + .into_iter() + .rev() + .map(ScanCursor::Path) + .collect::>(); + let mut visited_dirs = HashSet::new(); + + while let Some(cursor) = pending.pop() { + if cancellation.is_cancelled() { + break; + } + let original_path = match cursor { + ScanCursor::Path(path) => path, + ScanCursor::Directory(path, mut entries) => { + match entries.next() { + Some(Ok(entry)) => { + pending.push(ScanCursor::Directory(path, entries)); + pending.push(ScanCursor::Path(entry.path())); + } + Some(Err(error)) => { + pending.push(ScanCursor::Directory(path.clone(), entries)); + if sender + .blocking_send(ScannedImport::Failure(ImportFailure { + path: path.clone(), + error: format!( + "failed to read an entry in {}: {error}", + path.display() + ), + })) + .is_err() + { + break; + } + } + None => {} + } + continue; + } + }; + let path = canonical_path(&original_path); + let metadata = match std::fs::metadata(&path) { + Ok(metadata) => metadata, + Err(error) => { + let extension = path + .extension() + .map(|extension| extension.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + if (!recursive || BookFormat::from_extension(&extension).is_some()) + && sender + .blocking_send(ScannedImport::Failure(ImportFailure { + path, + error: format!( + "failed to inspect {}: {error}", + original_path.display() + ), + })) + .is_err() + { + break; + } + continue; + } + }; + if recursive && metadata.is_dir() { + if !visited_dirs.insert(path.clone()) { + continue; + } + match std::fs::read_dir(&path) { + Ok(entries) => pending.push(ScanCursor::Directory(path, entries)), + Err(error) => { + if sender + .blocking_send(ScannedImport::Failure(ImportFailure { + path: path.clone(), + error: format!("failed to read directory {}: {error}", path.display()), + })) + .is_err() + { + break; + } + } + } + continue; + } + if !metadata.is_file() { + continue; + } + + let extension = path + .extension() + .map(|extension| extension.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + let Some(format) = BookFormat::from_extension(&extension) else { + if !recursive + && sender + .blocking_send(ScannedImport::Failure(ImportFailure { + path, + error: format!("unsupported format: .{extension}"), + })) + .is_err() + { + break; + } + continue; + }; + progress.found_file(); + if sender + .blocking_send(ScannedImport::Candidate(PendingImportCandidate { + path, + format, + })) + .is_err() + { + break; + } + } + + progress.finish_enumerating(); +} + +/// Normalize user-visible import text for grouping and filtering. +pub fn normalize_import_text(text: &str) -> String { + text.nfc() + .case_fold() + .nfc() + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn import_group_key(path: &Path) -> String { + normalize_import_text(&filename_title(path)) +} + +#[derive(Debug)] struct ManagedStage { path: PathBuf, } @@ -1107,13 +1795,16 @@ impl Drop for ManagedStage { } fn unique_managed_path(parent: &Path, label: &str) -> PathBuf { + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + parent.join(format!( - ".{label}.{}.{}.tmp", + ".{label}.{}.{}.{}.tmp", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() - .as_nanos() + .as_nanos(), + NEXT_ID.fetch_add(1, Ordering::Relaxed) )) } @@ -1337,6 +2028,90 @@ fn encode_cover_png(width: u32, height: u32, rgba: &[u8]) -> Option> { mod tests { use super::*; + struct CancellingReader { + cancellation: ImportCancellation, + reads: usize, + } + + impl std::io::Read for CancellingReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + self.reads += 1; + buffer[0] = 1; + self.cancellation.cancel(); + Ok(1) + } + } + + #[test] + fn fingerprinting_checks_for_cancellation_between_chunks() { + let cancellation = ImportCancellation::default(); + let reader = CancellingReader { + cancellation: cancellation.clone(), + reads: 0, + }; + + let Err(error) = fingerprint_reader(reader, 2, Some(&cancellation)) else { + panic!("fingerprinting should stop after cancellation"); + }; + + assert!(error.to_string().contains("discovery cancelled")); + } + + #[test] + fn scanner_streams_candidates_before_directory_enumeration_finishes() { + let directory = tempfile::tempdir().unwrap(); + for index in 0..20 { + std::fs::write(directory.path().join(format!("book-{index}.epub")), b"book").unwrap(); + } + let cancellation = ImportCancellation::default(); + let progress = ImportDiscoveryProgress::default(); + let (sender, mut receiver) = tokio::sync::mpsc::channel(1); + let scan_path = directory.path().to_path_buf(); + let scan_cancellation = cancellation.clone(); + let scan_progress = progress.clone(); + let scanner = std::thread::spawn(move || { + scan_import_candidates( + vec![scan_path], + true, + &scan_cancellation, + &scan_progress, + &sender, + ); + }); + + assert!(matches!( + receiver.blocking_recv(), + Some(ScannedImport::Candidate(_)) + )); + assert!(progress.snapshot().enumerating); + + cancellation.cancel(); + drop(receiver); + scanner.join().unwrap(); + } + + #[tokio::test] + async fn fingerprint_task_failure_is_reported_without_a_fake_candidate() { + let progress = ImportDiscoveryProgress::default(); + progress.found_file(); + let mut tasks = + tokio::task::JoinSet::<(PendingImportCandidate, Result)>::new(); + tasks.spawn(async { panic!("fingerprint failed") }); + let result = tasks.join_next().await.unwrap(); + let mut fingerprinted = Vec::new(); + let mut failures = Vec::new(); + + collect_fingerprint_result(result, &progress, &mut fingerprinted, &mut failures); + + assert!(fingerprinted.is_empty()); + assert_eq!(failures.len(), 1); + assert!(failures[0].path.as_os_str().is_empty()); + assert!(failures[0].error.contains("fingerprint task failed")); + let snapshot = progress.snapshot(); + assert_eq!(snapshot.hashed_files, 1); + assert_eq!(snapshot.completed_files, 1); + } + #[test] fn publishing_rejects_a_stage_that_does_not_match_its_expected_hash() { let directory = tempfile::tempdir().unwrap(); diff --git a/crates/shosai-core/src/reading_state.rs b/crates/shosai-core/src/reading_state.rs index 0c91328..6d8895d 100644 --- a/crates/shosai-core/src/reading_state.rs +++ b/crates/shosai-core/src/reading_state.rs @@ -1,21 +1,134 @@ //! Persistence for per-file reading state (last page, zoom level, etc.). //! //! State is stored in a SQLite database in the user's data directory: -//! - Linux: `~/.local/share/shosai/shosai.db` -//! - macOS: `~/Library/Application Support/shosai/shosai.db` +//! - Linux: `~/.local/share/shosai[-dev]/shosai.db` +//! - macOS: `~/Library/Application Support/shosai[-dev]/shosai.db` //! //! Uses sqlx with SQLite so the same database can be extended for library //! management in future phases. +use std::fs::OpenOptions; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use sqlx::Row; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqliteSynchronous}; -const APP_DIR: &str = "shosai"; +/// Data directory used by normal/release launches. +pub const RELEASE_APP_DIR: &str = "shosai"; +/// Isolated data directory used when `SHOSAI_DEV_BUILD=1`. +pub const DEVELOPMENT_APP_DIR: &str = "shosai-dev"; +/// File proving that an external managed-library directory belongs to the +/// development profile and may be removed by the development reset tool. +pub const STORAGE_PROFILE_MARKER_FILE: &str = ".shosai-storage-profile"; +/// Exact marker contents required for development-owned external storage. +pub const DEVELOPMENT_STORAGE_PROFILE: &str = "shosai-development-v1"; const DB_FILE: &str = "shosai.db"; +fn select_development_profile(runtime: Option<&str>, compiled: Option<&str>, debug: bool) -> bool { + match runtime { + Some("1") => true, + Some("0") => false, + _ => compiled == Some("1") || debug, + } +} + +/// Whether this process uses isolated development storage and branding. +pub fn is_development_profile() -> bool { + select_development_profile( + std::env::var("SHOSAI_DEV_BUILD").ok().as_deref(), + option_env!("SHOSAI_DEV_BUILD"), + cfg!(debug_assertions), + ) +} + +/// Return the managed application directory name for this process. +/// +/// Debug builds default to development storage. `SHOSAI_DEV_BUILD=1` also +/// isolates release-mode development runs, while `0` permits explicit +/// production-profile testing. +pub fn app_data_directory_name() -> &'static str { + if is_development_profile() { + DEVELOPMENT_APP_DIR + } else { + RELEASE_APP_DIR + } +} + +/// Return the profile-specific folder created below a user-selected library parent. +pub fn managed_library_folder_name() -> &'static str { + if is_development_profile() { + "Shosai Dev" + } else { + "Shosai" + } +} + +/// Claim a user-selected managed-library directory for the current profile. +/// +/// Development refuses to adopt a non-empty directory without its matching +/// marker so `make reset` can never infer ownership from a folder name alone. +pub fn prepare_managed_library_directory(path: &Path) -> Result<()> { + if !is_development_profile() { + std::fs::create_dir_all(path) + .with_context(|| format!("failed to create managed library {}", path.display()))?; + return Ok(()); + } + + if path.exists() { + reject_symlink(path, "managed library")?; + let marker = path.join(STORAGE_PROFILE_MARKER_FILE); + if marker.symlink_metadata().is_ok() { + return validate_managed_library_directory(path); + } + anyhow::bail!( + "refusing to use existing directory without a Shosai development ownership marker" + ); + } else { + std::fs::create_dir_all(path) + .with_context(|| format!("failed to create managed library {}", path.display()))?; + } + let marker = path.join(STORAGE_PROFILE_MARKER_FILE); + use std::io::Write; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&marker) + .with_context(|| format!("failed to create storage marker {}", marker.display()))?; + writeln!(file, "{DEVELOPMENT_STORAGE_PROFILE}") + .with_context(|| format!("failed to write storage marker {}", marker.display()))?; + Ok(()) +} + +/// Verify that development-owned external storage still has its regular marker. +pub fn validate_managed_library_directory(path: &Path) -> Result<()> { + if !is_development_profile() { + return Ok(()); + } + reject_symlink(path, "managed library")?; + let marker = path.join(STORAGE_PROFILE_MARKER_FILE); + reject_symlink(&marker, "managed library marker")?; + let profile = std::fs::read_to_string(&marker) + .with_context(|| format!("failed to read storage marker {}", marker.display()))?; + if profile.trim() != DEVELOPMENT_STORAGE_PROFILE { + anyhow::bail!("managed library belongs to a different Shosai profile"); + } + Ok(()) +} + +fn reject_symlink(path: &Path, description: &str) -> Result<()> { + let metadata = path + .symlink_metadata() + .with_context(|| format!("failed to inspect {description} {}", path.display()))?; + if metadata.file_type().is_symlink() { + anyhow::bail!( + "refusing to use symlinked {description}: {}", + path.display() + ); + } + Ok(()) +} + /// Per-file reading state. #[derive(Debug, Clone)] pub struct FileReadingState { @@ -70,6 +183,9 @@ impl ReadingStateStore { /// Async: open at default platform path. pub async fn open_async() -> Result { let path = db_file_path()?; + if is_development_profile() { + prepare_development_data_directory(path.parent().context("database has no parent")?)?; + } Self::open_at_async(&path).await } @@ -295,6 +411,24 @@ impl ReadingStateStore { } } +fn prepare_development_data_directory(path: &Path) -> Result<()> { + std::fs::create_dir_all(path) + .with_context(|| format!("failed to create development data dir {}", path.display()))?; + reject_symlink(path, "development data directory")?; + let marker = path.join(STORAGE_PROFILE_MARKER_FILE); + if marker.symlink_metadata().is_ok() { + reject_symlink(&marker, "development data marker")?; + let profile = std::fs::read_to_string(&marker) + .with_context(|| format!("failed to read data marker {}", marker.display()))?; + if profile.trim() != DEVELOPMENT_STORAGE_PROFILE { + anyhow::bail!("development data directory belongs to a different Shosai profile"); + } + return Ok(()); + } + std::fs::write(&marker, format!("{DEVELOPMENT_STORAGE_PROFILE}\n")) + .with_context(|| format!("failed to write data marker {}", marker.display())) +} + /// Convert a file path to a canonical string key. fn canonical_key(path: &Path) -> String { path.canonicalize() @@ -306,7 +440,7 @@ fn canonical_key(path: &Path) -> String { /// Get the path to the database file. fn db_file_path() -> Result { let data_dir = data_dir()?; - Ok(data_dir.join(APP_DIR).join(DB_FILE)) + Ok(data_dir.join(app_data_directory_name()).join(DB_FILE)) } /// Get the platform-specific data directory. @@ -329,3 +463,51 @@ fn data_dir() -> Result { Ok(home.join(".local").join("share")) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn storage_profile_selection_honors_runtime_and_compiled_flags() { + assert!(select_development_profile(Some("1"), None, false)); + assert!(!select_development_profile(Some("0"), Some("1"), true)); + assert!(select_development_profile(None, Some("1"), false)); + assert!(!select_development_profile(None, Some("0"), false)); + assert!(select_development_profile(None, None, true)); + assert!(!select_development_profile(None, None, false)); + } + + #[cfg(unix)] + #[test] + fn development_storage_rejects_symlinked_markers_and_directories() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().unwrap(); + let unrelated = directory.path().join("unrelated"); + std::fs::create_dir(&unrelated).unwrap(); + let profile = directory.path().join("profile"); + std::fs::write(&profile, DEVELOPMENT_STORAGE_PROFILE).unwrap(); + symlink(&profile, unrelated.join(STORAGE_PROFILE_MARKER_FILE)).unwrap(); + + let marker_error = validate_managed_library_directory(&unrelated).unwrap_err(); + assert!(marker_error.to_string().contains("symlinked")); + + let linked = directory.path().join("linked"); + symlink(&unrelated, &linked).unwrap(); + let directory_error = validate_managed_library_directory(&linked).unwrap_err(); + assert!(directory_error.to_string().contains("symlinked")); + } + + #[test] + fn development_storage_does_not_claim_an_existing_unmarked_directory() { + let directory = tempfile::tempdir().unwrap(); + let existing = directory.path().join("existing"); + std::fs::create_dir(&existing).unwrap(); + + let error = prepare_managed_library_directory(&existing).unwrap_err(); + + assert!(error.to_string().contains("existing directory")); + assert!(!existing.join(STORAGE_PROFILE_MARKER_FILE).exists()); + } +} diff --git a/crates/shosai-core/tests/library_tests.rs b/crates/shosai-core/tests/library_tests.rs index 0693046..c485162 100644 --- a/crates/shosai-core/tests/library_tests.rs +++ b/crates/shosai-core/tests/library_tests.rs @@ -1,5 +1,8 @@ use shosai_core::bookmarks::BookmarkStore; -use shosai_core::library::{BookFormat, Library, MANAGED_LIBRARY_DIR_PREFERENCE, StorageKind}; +use shosai_core::library::{ + BookFormat, ImportCancellation, ImportDiscoveryProgress, ImportDuplicate, Library, + MANAGED_LIBRARY_DIR_PREFERENCE, StorageKind, +}; use shosai_core::reading_state::{FileReadingState, ReadingStateStore}; use std::path::PathBuf; use tempfile::TempDir; @@ -231,6 +234,307 @@ async fn test_import_directory() { assert!(report.failures.is_empty()); } +#[tokio::test] +async fn discovery_groups_exact_filename_stems_without_importing() { + let (lib, _, dir) = temp_library().await; + let import_dir = dir.path().join("imports"); + std::fs::create_dir_all(&import_dir).unwrap(); + std::fs::copy( + fixture_path("sample.pdf"), + import_dir.join("Learning Rust.pdf"), + ) + .unwrap(); + std::fs::copy( + fixture_path("sample.epub"), + import_dir.join("Learning Rust.epub"), + ) + .unwrap(); + std::fs::write(import_dir.join("notes.txt"), "not a book").unwrap(); + + let discovery = lib.discover_directory(&import_dir).await; + + assert_eq!(discovery.candidates.len(), 2); + assert!(discovery.failures.is_empty()); + assert_eq!(discovery.candidates[0].group_key, "learning rust"); + assert_eq!(discovery.candidates[1].group_key, "learning rust"); + assert_ne!( + discovery.candidates[0].format, + discovery.candidates[1].format + ); + assert!(lib.list_all().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn discovery_marks_books_already_in_the_library() { + let (lib, _, _dir) = temp_library().await; + let book = lib.import_file(&fixture_path("sample.pdf")).await.unwrap(); + + let discovery = lib.discover_files(&[fixture_path("sample.pdf")]).await; + + assert_eq!(discovery.candidates.len(), 1); + assert_eq!( + discovery.candidates[0].duplicate, + Some(ImportDuplicate::ExistingBook { + book_id: book.id, + title: book.title, + }) + ); +} + +#[tokio::test] +async fn same_content_duplicates_choose_the_lowest_referenced_book_id() { + let (lib, _, dir) = temp_library().await; + let first = dir.path().join("first.pdf"); + let second = dir.path().join("second.pdf"); + let third = dir.path().join("third.pdf"); + for path in [&first, &second, &third] { + std::fs::copy(fixture_path("sample.pdf"), path).unwrap(); + } + let first_book = lib.import_file(&first).await.unwrap(); + let second_book = lib.import_file(&second).await.unwrap(); + assert!(first_book.id < second_book.id); + + let discovery = lib.discover_files(std::slice::from_ref(&third)).await; + + assert_eq!( + discovery.candidates[0].duplicate, + Some(ImportDuplicate::ExistingBook { + book_id: first_book.id, + title: first_book.title, + }) + ); +} + +#[tokio::test] +async fn discovery_marks_repeated_content_in_the_selection() { + let (lib, _, dir) = temp_library().await; + let first = dir.path().join("a.pdf"); + let second = dir.path().join("b.pdf"); + std::fs::copy(fixture_path("sample.pdf"), &first).unwrap(); + std::fs::copy(fixture_path("sample.pdf"), &second).unwrap(); + + let discovery = lib.discover_files(&[second.clone(), first.clone()]).await; + + assert_eq!(discovery.candidates.len(), 2); + assert!(discovery.candidates[0].duplicate.is_none()); + assert_eq!( + discovery.candidates[1].duplicate, + Some(ImportDuplicate::SelectedFile { + path: first.canonicalize().unwrap(), + }) + ); +} + +#[tokio::test] +async fn discovery_failure_order_is_stable() { + let (lib, _, dir) = temp_library().await; + let first = dir.path().join("a.pdf"); + let second = dir.path().join("z.epub"); + + let forward = lib.discover_files(&[first.clone(), second.clone()]).await; + let reverse = lib.discover_files(&[second.clone(), first.clone()]).await; + + let forward_paths: Vec<_> = forward + .failures + .iter() + .map(|failure| &failure.path) + .collect(); + let reverse_paths: Vec<_> = reverse + .failures + .iter() + .map(|failure| &failure.path) + .collect(); + assert_eq!(forward_paths, vec![&first, &second]); + assert_eq!(reverse_paths, forward_paths); +} + +#[tokio::test] +async fn discovery_groups_unicode_equivalent_stems() { + let (lib, _, dir) = temp_library().await; + let paths = [ + ("Straße.pdf", "sample.pdf"), + ("STRASSE.epub", "sample.epub"), + ("か\u{3099}.pdf", "sample.pdf"), + ("が.epub", "sample.epub"), + ] + .map(|(name, fixture)| { + let path = dir.path().join(name); + std::fs::copy(fixture_path(fixture), &path).unwrap(); + path + }); + + let discovery = lib.discover_files(&paths).await; + + let mut keys: Vec<_> = discovery + .candidates + .iter() + .map(|candidate| candidate.group_key.as_str()) + .collect(); + keys.sort_unstable(); + assert_eq!(keys, ["strasse", "strasse", "が", "が"]); +} + +#[cfg(unix)] +#[tokio::test] +async fn discovery_skips_non_regular_files() { + use std::os::unix::net::UnixListener; + + let (lib, _, dir) = temp_library().await; + let socket_path = dir.path().join("not-a-book.pdf"); + let _listener = UnixListener::bind(&socket_path).unwrap(); + + let discovery = lib.discover_directory(dir.path()).await; + + assert!(discovery.candidates.is_empty()); + assert!(discovery.failures.is_empty()); +} + +#[cfg(unix)] +#[tokio::test] +async fn discovery_uses_a_symlink_targets_format_and_path() { + use std::os::unix::fs::symlink; + + let (lib, _, dir) = temp_library().await; + let target = dir.path().join("actual.epub"); + let alias = dir.path().join("misleading.pdf"); + std::fs::copy(fixture_path("sample.epub"), &target).unwrap(); + symlink(&target, &alias).unwrap(); + + let discovery = lib.discover_files(&[alias]).await; + + assert_eq!(discovery.candidates.len(), 1); + assert_eq!(discovery.candidates[0].format, BookFormat::Epub); + assert_eq!(discovery.candidates[0].path, target.canonicalize().unwrap()); + assert_eq!(discovery.candidates[0].title, "actual"); +} + +#[tokio::test] +async fn discovered_import_rejects_a_file_changed_after_review() { + use std::io::Write; + + let (lib, _, dir) = temp_library().await; + let source = dir.path().join("changing.epub"); + std::fs::copy(fixture_path("sample.epub"), &source).unwrap(); + let discovery = lib.discover_files(std::slice::from_ref(&source)).await; + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&source) + .unwrap(); + file.write_all(b"changed after review").unwrap(); + + let linked = lib.link_discovered_files(&discovery.candidates).await; + let copied = lib.import_discovered_files(&discovery.candidates).await; + + assert!(linked.books.is_empty()); + assert_eq!(linked.failures.len(), 1); + assert!(linked.failures[0].error.contains("changed after review")); + assert!(copied.books.is_empty()); + assert_eq!(copied.failures.len(), 1); + assert!(copied.failures[0].error.contains("changed after review")); + assert!(lib.list_all().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn discovered_referenced_duplicate_rejects_a_file_changed_after_review() { + use std::io::Write; + + let (lib, _, dir) = temp_library().await; + let source = dir.path().join("changing.epub"); + std::fs::copy(fixture_path("sample.epub"), &source).unwrap(); + lib.import_file(&source).await.unwrap(); + let discovery = lib.discover_files(std::slice::from_ref(&source)).await; + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&source) + .unwrap(); + file.write_all(b"changed after review").unwrap(); + + let linked = lib.link_discovered_files(&discovery.candidates).await; + + assert!(linked.books.is_empty()); + assert_eq!(linked.failures.len(), 1); + assert!(linked.failures[0].error.contains("changed after review")); +} + +#[tokio::test] +async fn managed_import_preparation_is_concurrent_safe_and_does_not_mutate_the_library() { + let (lib, _, dir) = temp_library().await; + let first = dir.path().join("first.epub"); + let second = dir.path().join("second.epub"); + std::fs::copy(fixture_path("sample.epub"), &first).unwrap(); + std::fs::copy(fixture_path("sample.epub"), &second).unwrap(); + let mut candidates = lib.discover_files(&[first, second]).await.candidates; + let second_candidate = candidates.pop().unwrap(); + let first_candidate = candidates.pop().unwrap(); + + let (first_prepared, second_prepared) = tokio::join!( + lib.prepare_discovered_managed_file(first_candidate), + lib.prepare_discovered_managed_file(second_candidate), + ); + let first_prepared = first_prepared.unwrap(); + let second_prepared = second_prepared.unwrap(); + + assert!(lib.list_all().await.unwrap().is_empty()); + let first_book = lib + .commit_prepared_managed_file(&first_prepared) + .await + .unwrap(); + let second_book = lib + .commit_prepared_managed_file(&second_prepared) + .await + .unwrap(); + + assert_eq!(first_book.id, second_book.id); + assert_eq!(lib.list_all().await.unwrap().len(), 1); +} + +#[tokio::test] +async fn cancelled_discovery_stops_before_scanning_candidates() { + let (lib, _, _dir) = temp_library().await; + let cancellation = ImportCancellation::default(); + cancellation.cancel(); + + let discovery = lib + .discover_files_cancellable(vec![fixture_path("sample.epub")], cancellation) + .await; + + assert!(discovery.candidates.is_empty()); + assert!(discovery.failures.is_empty()); +} + +#[tokio::test] +async fn discovery_progress_counts_supported_files_through_completion() { + let (lib, _, dir) = temp_library().await; + let first = dir.path().join("book.epub"); + let second = dir.path().join("book.pdf"); + std::fs::copy(fixture_path("sample.epub"), &first).unwrap(); + std::fs::copy(fixture_path("sample.pdf"), &second).unwrap(); + for index in 3..=6 { + std::fs::copy( + fixture_path("sample.epub"), + dir.path().join(format!("book-{index}.epub")), + ) + .unwrap(); + } + std::fs::write(dir.path().join("notes.txt"), b"ignored").unwrap(); + let progress = ImportDiscoveryProgress::default(); + + let discovery = lib + .discover_directory_with_progress( + dir.path().to_path_buf(), + ImportCancellation::default(), + progress.clone(), + ) + .await; + + assert_eq!(discovery.candidates.len(), 6); + let snapshot = progress.snapshot(); + assert!(!snapshot.enumerating); + assert_eq!(snapshot.total_files, 6); + assert_eq!(snapshot.hashed_files, 6); + assert_eq!(snapshot.completed_files, 6); +} + #[tokio::test] async fn directory_import_reports_when_every_supported_file_fails() { let (lib, _, dir) = temp_library().await; @@ -570,15 +874,22 @@ async fn concurrent_identical_managed_imports_return_the_same_book() { let (lib, _, dir) = temp_library().await; let first = dir.path().join("first.epub"); let second = dir.path().join("second.epub"); + let third = dir.path().join("third.epub"); + let fourth = dir.path().join("fourth.epub"); std::fs::copy(fixture_path("sample.epub"), &first).unwrap(); std::fs::copy(fixture_path("sample.epub"), &second).unwrap(); + std::fs::copy(fixture_path("sample.epub"), &third).unwrap(); + std::fs::copy(fixture_path("sample.epub"), &fourth).unwrap(); - let (first_result, second_result) = tokio::join!( + let results = tokio::join!( lib.import_managed_file(&first), - lib.import_managed_file(&second) + lib.import_managed_file(&second), + lib.import_managed_file(&third), + lib.import_managed_file(&fourth), ); - assert_eq!(first_result.unwrap().id, second_result.unwrap().id); + let ids = [results.0, results.1, results.2, results.3].map(|result| result.unwrap().id); + assert!(ids.iter().all(|id| *id == ids[0])); assert_eq!(lib.list_all().await.unwrap().len(), 1); } diff --git a/scripts/reset-local-data.py b/scripts/reset-local-data.py new file mode 100644 index 0000000..9ca9dba --- /dev/null +++ b/scripts/reset-local-data.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Delete development Shosai state without touching production/user files.""" + +import os +from pathlib import Path +import secrets +import sqlite3 +import stat +import sys + +DEVELOPMENT_APP_DIR = "shosai-dev" +MARKER_FILE = ".shosai-storage-profile" +DEVELOPMENT_PROFILE = "shosai-development-v1" + + +def data_directory() -> Path: + if xdg_data_home := os.environ.get("XDG_DATA_HOME"): + return Path(xdg_data_home) / DEVELOPMENT_APP_DIR + home = Path(os.environ["HOME"]) + if sys.platform == "darwin": + return home / "Library" / "Application Support" / DEVELOPMENT_APP_DIR + return home / ".local" / "share" / DEVELOPMENT_APP_DIR + + +def is_within(path: Path, directory: Path) -> bool: + try: + path.resolve().relative_to(directory.resolve()) + return True + except ValueError: + return False + + +def open_owned_directory(directory: Path) -> tuple[int, int, os.stat_result]: + parent_fd = os.open(directory.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + directory_fd = os.open( + directory.name, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=parent_fd, + ) + directory_stat = os.fstat(directory_fd) + try: + marker_fd = os.open(MARKER_FILE, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory_fd) + try: + marker_stat = os.fstat(marker_fd) + if not stat.S_ISREG(marker_stat.st_mode): + raise OSError("storage marker is not a regular file") + with os.fdopen(marker_fd, encoding="utf-8", closefd=False) as marker: + if marker.read().strip() != DEVELOPMENT_PROFILE: + raise OSError("storage marker belongs to a different profile") + finally: + os.close(marker_fd) + except Exception: + os.close(directory_fd) + raise + return parent_fd, directory_fd, directory_stat + except Exception: + os.close(parent_fd) + raise + + +def remove_owned_directory(directory: Path) -> None: + parent_fd, directory_fd, expected = open_owned_directory(directory) + quarantine = f".{directory.name}.shosai-reset-{secrets.token_hex(8)}" + try: + os.rename(directory.name, quarantine, src_dir_fd=parent_fd, dst_dir_fd=parent_fd) + moved = os.stat(quarantine, dir_fd=parent_fd, follow_symlinks=False) + if (moved.st_dev, moved.st_ino) != (expected.st_dev, expected.st_ino): + os.rename(quarantine, directory.name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd) + raise OSError("managed directory changed while reset was validating it") + remove_directory_contents(directory_fd) + os.rmdir(quarantine, dir_fd=parent_fd) + finally: + os.close(directory_fd) + os.close(parent_fd) + + +def remove_directory_contents(directory_fd: int) -> None: + for name in os.listdir(directory_fd): + metadata = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + if stat.S_ISDIR(metadata.st_mode): + child_fd = os.open( + name, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=directory_fd, + ) + try: + remove_directory_contents(child_fd) + finally: + os.close(child_fd) + os.rmdir(name, dir_fd=directory_fd) + else: + os.unlink(name, dir_fd=directory_fd) + + +def unlink_managed_file(directory_fd: int, relative_path: Path) -> None: + parts = relative_path.parts + if not parts or any(part in ("", ".", "..") for part in parts): + raise OSError("managed path is not a safe relative file path") + current_fd = os.dup(directory_fd) + try: + for part in parts[:-1]: + next_fd = os.open( + part, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=current_fd, + ) + os.close(current_fd) + current_fd = next_fd + metadata = os.stat(parts[-1], dir_fd=current_fd, follow_symlinks=False) + if stat.S_ISDIR(metadata.st_mode): + raise OSError("managed book path is a directory") + os.unlink(parts[-1], dir_fd=current_fd) + except FileNotFoundError: + pass + finally: + os.close(current_fd) + + +def managed_book_data(database: Path) -> tuple[list[Path], Path | None]: + if not database.exists(): + return [], None + connection = sqlite3.connect(f"file:{database}?mode=ro", uri=True) + try: + columns = { + row[1] for row in connection.execute("PRAGMA table_info(books)").fetchall() + } + paths = [] + if {"file_path", "storage_kind"}.issubset(columns): + paths = [ + Path(row[0]) + for row in connection.execute( + "SELECT file_path FROM books WHERE storage_kind = 'managed'" + ) + ] + preference = connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'preferences'" + ).fetchone() + if preference is None: + return paths, None + row = connection.execute( + "SELECT value FROM preferences WHERE key = 'library.managed_books_dir'" + ).fetchone() + return paths, Path(row[0]) if row else None + finally: + connection.close() + + +def main() -> int: + root = data_directory() + database = root / "shosai.db" + failures = [] + + try: + managed_paths, custom_managed_directory = managed_book_data(database) + except sqlite3.Error as error: + print(f"Could not read {database}: {error}", file=sys.stderr) + print("Quit Shosai before running make reset.", file=sys.stderr) + return 1 + + external = None + external_handle = None + if custom_managed_directory is not None and not is_within(custom_managed_directory, root): + external = custom_managed_directory + if external.exists() or external.is_symlink(): + try: + external_handle = open_owned_directory(external) + except OSError as error: + print( + f"Refusing to remove external managed directory {external}: {error}", + file=sys.stderr, + ) + return 1 + + allowed_directories = [root] + ([external] if external is not None else []) + unsafe_paths = [ + path for path in managed_paths if not any(is_within(path, owned) for owned in allowed_directories) + ] + if unsafe_paths: + print("Refusing reset: managed database paths are outside development-owned storage:", file=sys.stderr) + for path in unsafe_paths: + print(f" {path}", file=sys.stderr) + if external_handle is not None: + os.close(external_handle[1]) + os.close(external_handle[0]) + return 1 + + if external is not None and external_handle is not None: + parent_fd, directory_fd, _ = external_handle + try: + external_root = external.resolve() + for path in managed_paths: + try: + relative = path.resolve().relative_to(external_root) + except ValueError: + continue + try: + unlink_managed_file(directory_fd, relative) + except OSError as error: + failures.append(f"{path}: {error}") + finally: + os.close(directory_fd) + os.close(parent_fd) + + if root.exists() or root.is_symlink(): + try: + remove_owned_directory(root) + except OSError as error: + failures.append(f"{root}: {error}") + + if failures: + print("Shosai could not remove all local data:", file=sys.stderr) + for failure in failures: + print(f" {failure}", file=sys.stderr) + return 1 + + print(f"Reset Shosai local data at {root}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_reset_local_data.py b/scripts/tests/test_reset_local_data.py new file mode 100644 index 0000000..74a641a --- /dev/null +++ b/scripts/tests/test_reset_local_data.py @@ -0,0 +1,186 @@ +import os +from pathlib import Path +import sqlite3 +import subprocess +import sys +import tempfile +import unittest + + +REPOSITORY = Path(__file__).resolve().parents[2] +RESET_SCRIPT = REPOSITORY / "scripts" / "reset-local-data.py" +MARKER = ".shosai-storage-profile" +PROFILE = "shosai-development-v1" + + +def create_database(root: Path, rows=(), custom: Path | None = None) -> None: + root.mkdir(parents=True) + (root / MARKER).write_text(PROFILE) + database = sqlite3.connect(root / "shosai.db") + database.execute("CREATE TABLE books (file_path TEXT, storage_kind TEXT NOT NULL)") + database.execute("CREATE TABLE preferences (key TEXT, value TEXT)") + database.executemany("INSERT INTO books VALUES (?, ?)", rows) + if custom is not None: + database.execute( + "INSERT INTO preferences VALUES ('library.managed_books_dir', ?)", + (str(custom),), + ) + database.commit() + database.close() + + +def run_reset(data_home: Path) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment["XDG_DATA_HOME"] = str(data_home) + return subprocess.run( + [sys.executable, str(RESET_SCRIPT)], + env=environment, + capture_output=True, + text=True, + check=False, + ) + + +class ResetLocalDataTests(unittest.TestCase): + def test_dev_reset_recursively_removes_dev_root_and_preserves_production(self): + with tempfile.TemporaryDirectory() as directory: + data_home = Path(directory) / "data" + production = data_home / "shosai" + development = data_home / "shosai-dev" + (production / "books").mkdir(parents=True) + (production / "books" / "production.epub").write_bytes(b"production") + (development / "nested").mkdir(parents=True) + (development / "nested" / "sentinel").write_text("dev") + (development / MARKER).write_text(PROFILE) + + result = run_reset(data_home) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(development.exists()) + self.assertTrue((production / "books" / "production.epub").exists()) + + def test_unmarked_development_root_is_preserved(self): + with tempfile.TemporaryDirectory() as directory: + data_home = Path(directory) / "data" + development = data_home / "shosai-dev" + development.mkdir(parents=True) + sentinel = development / "sentinel" + sentinel.write_text("keep") + + result = run_reset(data_home) + + self.assertNotEqual(result.returncode, 0) + self.assertTrue(sentinel.exists()) + + def test_managed_copies_are_removed_without_recursively_deleting_custom_directory(self): + with tempfile.TemporaryDirectory() as directory: + temporary = Path(directory) + data_home = temporary / "data" + development = data_home / "shosai-dev" + custom = temporary / "custom-library" + custom.mkdir() + (custom / MARKER).write_text(PROFILE) + managed = custom / "managed.epub" + managed.write_bytes(b"managed") + sentinel = custom / "unrelated.txt" + sentinel.write_text("keep") + referenced = temporary / "original.epub" + referenced.write_bytes(b"original") + create_database( + development, + [(str(managed), "managed"), (str(referenced), "referenced")], + custom, + ) + + result = run_reset(data_home) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(development.exists()) + self.assertTrue(custom.exists()) + self.assertFalse(managed.exists()) + self.assertTrue(sentinel.exists()) + self.assertTrue(referenced.exists()) + + def test_missing_or_wrong_marker_preserves_same_named_custom_directory(self): + for marker_value in (None, "production"): + with self.subTest(marker_value=marker_value), tempfile.TemporaryDirectory() as directory: + temporary = Path(directory) + data_home = temporary / "data" + development = data_home / "shosai-dev" + custom = temporary / "parent" / "Shosai" + custom.mkdir(parents=True) + sentinel = custom / "sentinel" + sentinel.write_text("keep") + if marker_value is not None: + (custom / MARKER).write_text(marker_value) + create_database(development, custom=custom) + + result = run_reset(data_home) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("Refusing", result.stderr) + self.assertTrue(sentinel.exists()) + self.assertTrue(development.exists()) + + @unittest.skipUnless(hasattr(os, "symlink"), "requires symlinks") + def test_symlinked_marker_cannot_claim_an_unrelated_directory(self): + with tempfile.TemporaryDirectory() as directory: + temporary = Path(directory) + data_home = temporary / "data" + development = data_home / "shosai-dev" + custom = temporary / "unrelated" + custom.mkdir() + sentinel = custom / "sentinel" + sentinel.write_text("keep") + profile = temporary / "profile" + profile.write_text(PROFILE) + (custom / MARKER).symlink_to(profile) + create_database(development, custom=custom) + + result = run_reset(data_home) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("Refusing", result.stderr) + self.assertTrue(sentinel.exists()) + self.assertTrue(development.exists()) + + @unittest.skipUnless(hasattr(os, "symlink"), "requires symlinks") + def test_symlinked_directory_is_never_removed(self): + with tempfile.TemporaryDirectory() as directory: + temporary = Path(directory) + data_home = temporary / "data" + development = data_home / "shosai-dev" + unrelated = temporary / "unrelated" + unrelated.mkdir() + sentinel = unrelated / "sentinel" + sentinel.write_text("keep") + custom = temporary / "custom-library" + custom.symlink_to(unrelated, target_is_directory=True) + create_database(development, custom=custom) + + result = run_reset(data_home) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("Refusing", result.stderr) + self.assertTrue(sentinel.exists()) + self.assertTrue(development.exists()) + + def test_out_of_scope_managed_row_cannot_delete_arbitrary_file(self): + with tempfile.TemporaryDirectory() as directory: + temporary = Path(directory) + data_home = temporary / "data" + development = data_home / "shosai-dev" + sentinel = temporary / "sentinel.epub" + sentinel.write_bytes(b"keep") + create_database(development, [(str(sentinel), "managed")]) + + result = run_reset(data_home) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("outside development-owned storage", result.stderr) + self.assertTrue(sentinel.exists()) + self.assertTrue(development.exists()) + + +if __name__ == "__main__": + unittest.main()