From d9e4dbc18e18fe12a574d4ab3400d059db3752dc Mon Sep 17 00:00:00 2001 From: sysid Date: Sun, 19 Oct 2025 16:15:54 +0200 Subject: [PATCH 1/2] feat: add --json flag for output --- ankiview/Cargo.lock | 1 + ankiview/Cargo.toml | 1 + ankiview/src/cli/args.rs | 4 ++ ankiview/src/domain/note.rs | 4 +- ankiview/src/lib.rs | 32 +++++---- .../tests/fixtures/build_test_collection.rs | 12 ++-- .../test_collection/collection.anki2-wal | 0 ankiview/tests/helpers/mod.rs | 15 ++--- ankiview/tests/test_anki.rs | 10 +-- ankiview/tests/test_cli.rs | 61 ++++++++++++++++- ankiview/tests/test_html_presenter.rs | 6 +- ankiview/tests/test_note_deleter.rs | 2 +- ankiview/tests/test_note_json.rs | 66 +++++++++++++++++++ ankiview/tests/test_note_viewer.rs | 48 +++++++++++++- 14 files changed, 224 insertions(+), 38 deletions(-) delete mode 100644 ankiview/tests/fixtures/test_collection/collection.anki2-wal create mode 100644 ankiview/tests/test_note_json.rs diff --git a/ankiview/Cargo.lock b/ankiview/Cargo.lock index 260315c..9db4a4b 100644 --- a/ankiview/Cargo.lock +++ b/ankiview/Cargo.lock @@ -231,6 +231,7 @@ dependencies = [ "regex", "rstest 0.24.0", "serde", + "serde_json", "tempfile", "thiserror 2.0.11", "tracing", diff --git a/ankiview/Cargo.toml b/ankiview/Cargo.toml index 8591f44..eb489d5 100644 --- a/ankiview/Cargo.toml +++ b/ankiview/Cargo.toml @@ -22,6 +22,7 @@ itertools = "0.14.0" regex = "1.11.1" rstest = "0.24.0" serde = { version = "1.0.218", features = ["derive"] } +serde_json = "1.0" tempfile = "3.17.1" thiserror = "2.0.11" tracing = "0.1.41" diff --git a/ankiview/src/cli/args.rs b/ankiview/src/cli/args.rs index ab7aedb..64e39a8 100644 --- a/ankiview/src/cli/args.rs +++ b/ankiview/src/cli/args.rs @@ -30,6 +30,10 @@ pub enum Command { /// Note ID to view #[arg(value_name = "NOTE_ID")] note_id: i64, + + /// Output note as JSON instead of opening in browser + #[arg(long)] + json: bool, }, /// Delete a note from the collection diff --git a/ankiview/src/domain/note.rs b/ankiview/src/domain/note.rs index 376cd90..d5d9b71 100644 --- a/ankiview/src/domain/note.rs +++ b/ankiview/src/domain/note.rs @@ -1,5 +1,7 @@ // src/domain/note.rs -#[derive(Debug, Clone)] +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] pub struct Note { pub id: i64, pub front: String, diff --git a/ankiview/src/lib.rs b/ankiview/src/lib.rs index 7c66c5f..780754d 100644 --- a/ankiview/src/lib.rs +++ b/ankiview/src/lib.rs @@ -30,33 +30,41 @@ pub fn run(args: Args) -> Result<()> { // Route to appropriate handler based on command match args.command { - Command::View { note_id } => handle_view_command(note_id, collection_path), + Command::View { note_id, json } => handle_view_command(note_id, json, collection_path), Command::Delete { note_id } => handle_delete_command(note_id, collection_path), } } -fn handle_view_command(note_id: i64, collection_path: PathBuf) -> Result<()> { +fn handle_view_command(note_id: i64, json: bool, collection_path: PathBuf) -> Result<()> { let repository = AnkiRepository::new(&collection_path)?; let media_dir = repository.media_dir().to_path_buf(); // Initialize application let mut viewer = application::NoteViewer::new(repository); - // Initialize presentation - let presenter = HtmlPresenter::with_media_dir(media_dir); - let mut renderer = infrastructure::renderer::ContentRenderer::new(); - // Execute use case info!(note_id = note_id, "Viewing note"); let note = viewer.view_note(note_id)?; debug!(?note, "Retrieved note"); - let html = presenter.render(¬e); - debug!(?html, "Generated HTML"); - - // Create temporary file and open in browser - let temp_path = renderer.create_temp_file(&html)?; - renderer.open_in_browser(&temp_path)?; + // Branch on output format + if json { + // JSON output path + let json_output = + serde_json::to_string_pretty(¬e).context("Failed to serialize note to JSON")?; + println!("{}", json_output); + } else { + // Browser output path (existing behavior) + let presenter = HtmlPresenter::with_media_dir(media_dir); + let mut renderer = infrastructure::renderer::ContentRenderer::new(); + + let html = presenter.render(¬e); + debug!(?html, "Generated HTML"); + + // Create temporary file and open in browser + let temp_path = renderer.create_temp_file(&html)?; + renderer.open_in_browser(&temp_path)?; + } Ok(()) } diff --git a/ankiview/tests/fixtures/build_test_collection.rs b/ankiview/tests/fixtures/build_test_collection.rs index d588f17..e654ed4 100644 --- a/ankiview/tests/fixtures/build_test_collection.rs +++ b/ankiview/tests/fixtures/build_test_collection.rs @@ -48,11 +48,15 @@ fn main() -> anyhow::Result<()> { println!(" Back: A systems programming language\n"); println!(" Note 2:"); println!(" Front: What is the quadratic formula?"); - println!(r#" Back:
$x = \frac{{-b \pm \sqrt{{b^2 - 4ac}}}}{{2a}}$
"#); + println!( + r#" Back:
$x = \frac{{-b \pm \sqrt{{b^2 - 4ac}}}}{{2a}}$
"# + ); println!(); println!(" Note 3:"); println!(" Front: How to create a vector in Rust?"); - println!(r#" Back:
let v: Vec = vec![1, 2, 3];
"#); + println!( + r#" Back:
let v: Vec = vec![1, 2, 3];
"# + ); println!(); println!(" Note 4:"); println!(" Front: Rust logo"); @@ -100,8 +104,8 @@ fn create_test_media(media_dir: &std::path::Path) -> anyhow::Result<()> { 0x90, 0x77, 0x53, 0xDE, // CRC 0x00, 0x00, 0x00, 0x0C, // IDAT length 0x49, 0x44, 0x41, 0x54, // IDAT - 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, - 0x18, 0xDD, 0x8D, 0xB4, // CRC + 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xDD, 0x8D, + 0xB4, // CRC 0x00, 0x00, 0x00, 0x00, // IEND length 0x49, 0x45, 0x4E, 0x44, // IEND 0xAE, 0x42, 0x60, 0x82, // CRC diff --git a/ankiview/tests/fixtures/test_collection/collection.anki2-wal b/ankiview/tests/fixtures/test_collection/collection.anki2-wal deleted file mode 100644 index e69de29..0000000 diff --git a/ankiview/tests/helpers/mod.rs b/ankiview/tests/helpers/mod.rs index f1869ce..7d087dd 100644 --- a/ankiview/tests/helpers/mod.rs +++ b/ankiview/tests/helpers/mod.rs @@ -14,8 +14,7 @@ pub struct TestCollection { impl TestCollection { /// Create a new test collection by copying the fixture pub fn new() -> Result { - let temp_dir = tempfile::tempdir() - .context("Failed to create temporary directory")?; + let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?; let fixture_path = Self::fixture_collection_path(); let collection_path = temp_dir.path().join("collection.anki2"); @@ -29,11 +28,9 @@ impl TestCollection { let media_dir = temp_dir.path().join("collection.media"); if fixture_media.exists() { - copy_dir_all(&fixture_media, &media_dir) - .context("Failed to copy media directory")?; + copy_dir_all(&fixture_media, &media_dir).context("Failed to copy media directory")?; } else { - std::fs::create_dir_all(&media_dir) - .context("Failed to create media directory")?; + std::fs::create_dir_all(&media_dir).context("Failed to create media directory")?; } Ok(Self { @@ -76,9 +73,9 @@ fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> { #[allow(dead_code)] pub mod test_notes { // Notes with images - good for testing media path resolution - pub const DAG_NOTE: i64 = 1695797540370; // Has dag.png image - pub const STAR_SCHEMA: i64 = 1713763428669; // Has star-schema.png image - pub const MERCATOR: i64 = 1737647330399; // Has mercator.png and wsg-enu2.png images + pub const DAG_NOTE: i64 = 1695797540370; // Has dag.png image + pub const STAR_SCHEMA: i64 = 1713763428669; // Has star-schema.png image + pub const MERCATOR: i64 = 1737647330399; // Has mercator.png and wsg-enu2.png images // Text-heavy notes - good for testing content rendering pub const TREE: i64 = 1695797540371; diff --git a/ankiview/tests/test_anki.rs b/ankiview/tests/test_anki.rs index a662ecd..0a7fd04 100644 --- a/ankiview/tests/test_anki.rs +++ b/ankiview/tests/test_anki.rs @@ -3,7 +3,7 @@ mod helpers; use ankiview::application::NoteRepository; use ankiview::domain::DomainError; use anyhow::Result; -use helpers::{TestCollection, test_notes}; +use helpers::{test_notes, TestCollection}; // Existing test (now un-ignored) #[test] @@ -36,8 +36,8 @@ fn given_dag_note_when_getting_note_then_returns_note_with_image() -> Result<()> // Assert assert_eq!(note.id, test_notes::DAG_NOTE); assert!(note.front.contains("DAG")); - assert!(note.back.contains("dag.png")); // Has image reference - assert!(!note.model_name.is_empty()); // Has a model name + assert!(note.back.contains("dag.png")); // Has image reference + assert!(!note.model_name.is_empty()); // Has a model name Ok(()) } @@ -67,8 +67,8 @@ fn given_star_schema_note_when_getting_note_then_returns_html_content() -> Resul let note = repo.get_note(test_notes::STAR_SCHEMA)?; // Assert - assert!(note.back.contains("

")); // Has HTML heading - assert!(note.back.contains("star-schema.png")); // Has image + assert!(note.back.contains("

")); // Has HTML heading + assert!(note.back.contains("star-schema.png")); // Has image assert!(note.back.contains("Fact Table")); Ok(()) } diff --git a/ankiview/tests/test_cli.rs b/ankiview/tests/test_cli.rs index 6c29c66..69ccbaf 100644 --- a/ankiview/tests/test_cli.rs +++ b/ankiview/tests/test_cli.rs @@ -21,8 +21,9 @@ fn given_explicit_view_command_when_parsing_then_succeeds() { // Assert match parsed.command { - Command::View { note_id } => { + Command::View { note_id, json } => { assert_eq!(note_id, 1234567890); + assert_eq!(json, false); } _ => panic!("Expected View command"), } @@ -87,8 +88,9 @@ fn given_global_profile_flag_when_parsing_then_succeeds() { // Assert match parsed.command { - Command::View { note_id } => { + Command::View { note_id, json } => { assert_eq!(note_id, 1234567890); + assert_eq!(json, false); } _ => panic!("Expected View command"), } @@ -134,3 +136,58 @@ fn given_collection_flag_after_subcommand_when_parsing_then_succeeds() { Some(std::path::PathBuf::from("/path/to/collection.anki2")) ); } + +#[test] +fn given_json_flag_when_parsing_view_command_then_json_is_true() { + // Arrange + let args = vec!["ankiview", "view", "--json", "1234567890"]; + + // Act + let parsed = Args::try_parse_from(args).unwrap(); + + // Assert + match parsed.command { + Command::View { note_id, json } => { + assert_eq!(note_id, 1234567890); + assert_eq!(json, true); + } + _ => panic!("Expected View command"), + } +} + +#[test] +fn given_no_json_flag_when_parsing_view_command_then_json_is_false() { + // Arrange + let args = vec!["ankiview", "view", "1234567890"]; + + // Act + let parsed = Args::try_parse_from(args).unwrap(); + + // Assert + match parsed.command { + Command::View { note_id, json } => { + assert_eq!(note_id, 1234567890); + assert_eq!(json, false); + } + _ => panic!("Expected View command"), + } +} + +#[test] +fn given_json_flag_with_global_flags_when_parsing_then_succeeds() { + // Arrange + let args = vec!["ankiview", "-v", "view", "--json", "1234567890"]; + + // Act + let parsed = Args::try_parse_from(args).unwrap(); + + // Assert + match parsed.command { + Command::View { note_id, json } => { + assert_eq!(note_id, 1234567890); + assert_eq!(json, true); + } + _ => panic!("Expected View command"), + } + assert_eq!(parsed.verbose, 1); +} diff --git a/ankiview/tests/test_html_presenter.rs b/ankiview/tests/test_html_presenter.rs index 87a4137..cf6f0ba 100644 --- a/ankiview/tests/test_html_presenter.rs +++ b/ankiview/tests/test_html_presenter.rs @@ -3,7 +3,7 @@ mod helpers; use ankiview::application::NoteRepository; use ankiview::ports::HtmlPresenter; use anyhow::Result; -use helpers::{TestCollection, test_notes}; +use helpers::{test_notes, TestCollection}; #[test] fn given_dag_note_when_rendering_with_media_dir_then_converts_to_file_uri() -> Result<()> { @@ -39,9 +39,9 @@ fn given_star_schema_note_when_rendering_then_processes_html_content() -> Result let html = presenter.render(¬e); // Assert - assert!(html.contains("file://")); // Image converted to file URI + assert!(html.contains("file://")); // Image converted to file URI assert!(html.contains("star-schema.png")); - assert!(html.contains("

")); // HTML structure preserved + assert!(html.contains("

")); // HTML structure preserved assert!(html.contains("Fact Table")); Ok(()) } diff --git a/ankiview/tests/test_note_deleter.rs b/ankiview/tests/test_note_deleter.rs index 40bd574..55cd637 100644 --- a/ankiview/tests/test_note_deleter.rs +++ b/ankiview/tests/test_note_deleter.rs @@ -3,7 +3,7 @@ mod helpers; use ankiview::application::{NoteDeleter, NoteRepository}; use ankiview::domain::DomainError; use anyhow::Result; -use helpers::{TestCollection, test_notes}; +use helpers::{test_notes, TestCollection}; #[test] fn given_existing_note_when_deleting_then_removes_note_and_cards() -> Result<()> { diff --git a/ankiview/tests/test_note_json.rs b/ankiview/tests/test_note_json.rs new file mode 100644 index 0000000..8e01280 --- /dev/null +++ b/ankiview/tests/test_note_json.rs @@ -0,0 +1,66 @@ +use ankiview::domain::Note; +use anyhow::Result; + +#[test] +fn given_note_when_serializing_to_json_then_contains_all_fields() -> Result<()> { + // Arrange + let note = Note { + id: 1234567890, + front: "Test front".to_string(), + back: "Test back".to_string(), + tags: vec!["tag1".to_string(), "tag2".to_string()], + model_name: "Basic".to_string(), + }; + + // Act + let json = serde_json::to_string_pretty(¬e)?; + + // Assert + assert!(json.contains(r#""id": 1234567890"#)); + assert!(json.contains(r#""front": "Test front""#)); + assert!(json.contains(r#""back": "Test back""#)); + assert!(json.contains(r#""tags": ["#)); + assert!(json.contains(r#""tag1""#)); + assert!(json.contains(r#""tag2""#)); + assert!(json.contains(r#""model_name": "Basic""#)); + Ok(()) +} + +#[test] +fn given_note_when_serializing_then_uses_snake_case_fields() -> Result<()> { + // Arrange + let note = Note { + id: 123, + front: "F".to_string(), + back: "B".to_string(), + tags: vec![], + model_name: "Model".to_string(), + }; + + // Act + let json = serde_json::to_string(¬e)?; + + // Assert - field names should be snake_case, not camelCase + assert!(json.contains(r#""model_name""#)); + assert!(!json.contains(r#""modelName""#)); + Ok(()) +} + +#[test] +fn given_note_with_empty_tags_when_serializing_then_produces_empty_array() -> Result<()> { + // Arrange + let note = Note { + id: 123, + front: "F".to_string(), + back: "B".to_string(), + tags: vec![], + model_name: "Model".to_string(), + }; + + // Act + let json = serde_json::to_string_pretty(¬e)?; + + // Assert + assert!(json.contains(r#""tags": []"#)); + Ok(()) +} diff --git a/ankiview/tests/test_note_viewer.rs b/ankiview/tests/test_note_viewer.rs index 514d2f4..f4f88bc 100644 --- a/ankiview/tests/test_note_viewer.rs +++ b/ankiview/tests/test_note_viewer.rs @@ -3,7 +3,7 @@ mod helpers; use ankiview::application::NoteViewer; use ankiview::ports::HtmlPresenter; use anyhow::Result; -use helpers::{TestCollection, test_notes}; +use helpers::{test_notes, TestCollection}; #[test] fn given_valid_note_id_when_viewing_note_then_returns_note() -> Result<()> { @@ -77,3 +77,49 @@ fn given_star_schema_note_when_viewing_and_rendering_then_resolves_media_paths() assert!(html.contains("star-schema.png")); Ok(()) } + +#[test] +fn given_valid_note_when_viewing_as_json_then_returns_valid_json_string() -> Result<()> { + // Arrange + let test_collection = TestCollection::new()?; + let repo = test_collection.open_repository()?; + let mut viewer = NoteViewer::new(repo); + + // Act + let note = viewer.view_note(test_notes::TREE)?; + let json = serde_json::to_string_pretty(¬e)?; + + // Assert + assert!(json.contains(r#""id":"#)); + assert!(json.contains(&test_notes::TREE.to_string())); + assert!(json.contains(r#""front":"#)); + assert!(json.contains(r#""back":"#)); + assert!(json.contains(r#""tags":"#)); + assert!(json.contains(r#""model_name":"#)); + + // Verify it's valid JSON by parsing it back + let parsed: serde_json::Value = serde_json::from_str(&json)?; + assert_eq!(parsed["id"].as_i64().unwrap(), test_notes::TREE); + + Ok(()) +} + +#[test] +fn given_note_with_html_content_when_serializing_to_json_then_preserves_html() -> Result<()> { + // Arrange + let test_collection = TestCollection::new()?; + let repo = test_collection.open_repository()?; + let mut viewer = NoteViewer::new(repo); + + // Act + let note = viewer.view_note(test_notes::DAG_NOTE)?; + let json = serde_json::to_string_pretty(¬e)?; + + // Assert - HTML tags should be preserved as strings + assert!(json.contains(r#""front":"#)); + // Note: The actual HTML content will be escaped in JSON strings + let parsed: serde_json::Value = serde_json::from_str(&json)?; + assert!(!parsed["front"].as_str().unwrap().is_empty()); + + Ok(()) +} From 210491242431924ce88628f358c7405005787e10 Mon Sep 17 00:00:00 2001 From: sysid Date: Sun, 19 Oct 2025 16:41:00 +0200 Subject: [PATCH 2/2] feat: list notes --- ankiview/src/application/mod.rs | 2 + ankiview/src/application/note_deleter.rs | 4 + ankiview/src/application/note_lister.rs | 123 +++++++++++++++++++++++ ankiview/src/application/note_viewer.rs | 5 + ankiview/src/cli/args.rs | 15 ++- ankiview/src/infrastructure/anki.rs | 48 +++++++++ ankiview/src/lib.rs | 21 ++++ ankiview/src/util/mod.rs | 1 + ankiview/src/util/text.rs | 92 +++++++++++++++++ ankiview/tests/test_anki.rs | 45 +++++++++ ankiview/tests/test_cli.rs | 52 ++++++++++ 11 files changed, 404 insertions(+), 4 deletions(-) create mode 100644 ankiview/src/application/note_lister.rs create mode 100644 ankiview/src/util/text.rs diff --git a/ankiview/src/application/mod.rs b/ankiview/src/application/mod.rs index f55cdc5..0359520 100644 --- a/ankiview/src/application/mod.rs +++ b/ankiview/src/application/mod.rs @@ -1,8 +1,10 @@ // src/application/mod.rs pub mod note_deleter; +pub mod note_lister; pub mod note_viewer; pub mod profile; pub use note_deleter::NoteDeleter; +pub use note_lister::NoteLister; pub use note_viewer::{NoteRepository, NoteViewer}; pub use profile::ProfileLocator; diff --git a/ankiview/src/application/note_deleter.rs b/ankiview/src/application/note_deleter.rs index ef04e09..ae491e1 100644 --- a/ankiview/src/application/note_deleter.rs +++ b/ankiview/src/application/note_deleter.rs @@ -41,6 +41,10 @@ mod tests { Err(DomainError::NoteNotFound(id)) } } + + fn list_notes(&mut self, _search_query: Option<&str>) -> Result, DomainError> { + unimplemented!("Not needed for deleter tests") + } } #[test] diff --git a/ankiview/src/application/note_lister.rs b/ankiview/src/application/note_lister.rs new file mode 100644 index 0000000..d69f6b4 --- /dev/null +++ b/ankiview/src/application/note_lister.rs @@ -0,0 +1,123 @@ +// src/application/note_lister.rs +use crate::application::NoteRepository; +use crate::domain::{DomainError, Note}; + +pub struct NoteLister { + repository: R, +} + +impl NoteLister { + pub fn new(repository: R) -> Self { + Self { repository } + } + + /// List all notes, or filter by search query + /// + /// # Arguments + /// * `search_query` - Optional search term to filter front field + /// + /// # Returns + /// Vector of notes matching the criteria + pub fn list_notes(&mut self, search_query: Option<&str>) -> Result, DomainError> { + self.repository.list_notes(search_query) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::Note; + + struct MockRepository { + notes: Vec, + } + + impl NoteRepository for MockRepository { + fn get_note(&mut self, id: i64) -> Result { + self.notes + .iter() + .find(|n| n.id == id) + .cloned() + .ok_or(DomainError::NoteNotFound(id)) + } + + fn delete_note(&mut self, _id: i64) -> Result { + unimplemented!() + } + + fn list_notes(&mut self, search_query: Option<&str>) -> Result, DomainError> { + Ok(match search_query { + None => self.notes.clone(), + Some(query) => self + .notes + .iter() + .filter(|n| n.front.contains(query)) + .cloned() + .collect(), + }) + } + } + + #[test] + fn given_no_search_when_listing_notes_then_returns_all_notes() { + // Arrange + let notes = vec![ + Note { + id: 1, + front: "First".to_string(), + back: "Back1".to_string(), + tags: vec![], + model_name: "Basic".to_string(), + }, + Note { + id: 2, + front: "Second".to_string(), + back: "Back2".to_string(), + tags: vec![], + model_name: "Basic".to_string(), + }, + ]; + let repo = MockRepository { + notes: notes.clone(), + }; + let mut lister = NoteLister::new(repo); + + // Act + let result = lister.list_notes(None).unwrap(); + + // Assert + assert_eq!(result.len(), 2); + } + + #[test] + fn given_search_query_when_listing_notes_then_returns_filtered_notes() { + // Arrange + let notes = vec![ + Note { + id: 1, + front: "What is a Tree?".to_string(), + back: "Back1".to_string(), + tags: vec![], + model_name: "Basic".to_string(), + }, + Note { + id: 2, + front: "What is a Graph?".to_string(), + back: "Back2".to_string(), + tags: vec![], + model_name: "Basic".to_string(), + }, + ]; + let repo = MockRepository { + notes: notes.clone(), + }; + let mut lister = NoteLister::new(repo); + + // Act + let result = lister.list_notes(Some("Tree")).unwrap(); + + // Assert + assert_eq!(result.len(), 1); + assert_eq!(result[0].id, 1); + } +} diff --git a/ankiview/src/application/note_viewer.rs b/ankiview/src/application/note_viewer.rs index 955e6cc..7af8d7b 100644 --- a/ankiview/src/application/note_viewer.rs +++ b/ankiview/src/application/note_viewer.rs @@ -8,6 +8,11 @@ pub trait NoteRepository { /// Delete a note and all associated cards from the collection /// Returns the number of cards deleted fn delete_note(&mut self, id: i64) -> Result; + + /// List notes, optionally filtered by a search query. + /// If search_query is None, returns all notes. + /// If search_query is Some(query), returns notes matching the query. + fn list_notes(&mut self, search_query: Option<&str>) -> Result, DomainError>; } pub struct NoteViewer { diff --git a/ankiview/src/cli/args.rs b/ankiview/src/cli/args.rs index 64e39a8..30521d9 100644 --- a/ankiview/src/cli/args.rs +++ b/ankiview/src/cli/args.rs @@ -6,10 +6,6 @@ use std::path::PathBuf; #[command(author, version, about, long_about = None)] // Read from `Cargo.toml` #[command(arg_required_else_help = true, disable_help_subcommand = true)] pub struct Args { - /// Subcommand to execute (view or delete) - #[command(subcommand)] - pub command: Command, - /// Path to Anki collection file (optional) #[arg(short, long, value_name = "COLLECTION", global = true)] pub collection: Option, @@ -21,6 +17,10 @@ pub struct Args { /// Verbosity level (-v = debug, -vv = trace) #[arg(short, long, action = clap::ArgAction::Count, global = true)] pub verbose: u8, + + /// Subcommand to execute (view, delete, or list) + #[command(subcommand)] + pub command: Command, } #[derive(Subcommand, Debug, Clone)] @@ -42,4 +42,11 @@ pub enum Command { #[arg(value_name = "NOTE_ID")] note_id: i64, }, + + /// List notes with ID and first line of front field + List { + /// Optional search term to filter notes by front field content + #[arg(value_name = "SEARCH")] + search: Option, + }, } diff --git a/ankiview/src/infrastructure/anki.rs b/ankiview/src/infrastructure/anki.rs index c9e124b..0ddcae6 100644 --- a/ankiview/src/infrastructure/anki.rs +++ b/ankiview/src/infrastructure/anki.rs @@ -131,4 +131,52 @@ impl NoteRepository for AnkiRepository { Ok(deleted_card_count) } + + #[instrument(level = "debug", skip(self))] + fn list_notes(&mut self, search_query: Option<&str>) -> Result, DomainError> { + // Get note IDs based on search query + let note_ids: Vec = match search_query { + None => { + // No search - get all notes (fastest method) + self.collection + .storage + .get_all_note_ids() + .map_err(|e| DomainError::CollectionError(e.to_string()))? + .into_iter() + .collect() + } + Some(query) => { + // Build search query for front field + let search_str = if query.is_empty() { + // Empty query string = all notes + "".to_string() + } else { + // Search in front field for the query string + format!("front:*{}*", query) + }; + + // Use unordered search (faster, no sort needed) + self.collection + .search_notes_unordered(&search_str) + .map_err(|e| DomainError::CollectionError(e.to_string()))? + } + }; + + // Fetch full note data for each ID + let mut notes = Vec::new(); + for note_id in note_ids { + // Use existing get_note logic + match self.get_note(note_id.0) { + Ok(note) => notes.push(note), + Err(DomainError::NoteNotFound(_)) => { + // Skip notes that don't exist (race condition or corrupted DB) + debug!(note_id = note_id.0, "Skipping note that doesn't exist"); + continue; + } + Err(e) => return Err(e), // Propagate other errors + } + } + + Ok(notes) + } } diff --git a/ankiview/src/lib.rs b/ankiview/src/lib.rs index 780754d..498ddba 100644 --- a/ankiview/src/lib.rs +++ b/ankiview/src/lib.rs @@ -32,6 +32,7 @@ pub fn run(args: Args) -> Result<()> { match args.command { Command::View { note_id, json } => handle_view_command(note_id, json, collection_path), Command::Delete { note_id } => handle_delete_command(note_id, collection_path), + Command::List { search } => handle_list_command(search.as_deref(), collection_path), } } @@ -92,6 +93,26 @@ fn handle_delete_command(note_id: i64, collection_path: PathBuf) -> Result<()> { Ok(()) } +fn handle_list_command(search_query: Option<&str>, collection_path: PathBuf) -> Result<()> { + let repository = AnkiRepository::new(&collection_path)?; + + // Initialize application + let mut lister = application::NoteLister::new(repository); + + // Execute use case + info!(?search_query, "Listing notes"); + let notes = lister.list_notes(search_query)?; + debug!(note_count = notes.len(), "Retrieved notes"); + + // Format and print output + for note in notes { + let first_line = util::text::extract_first_line(¬e.front); + println!("{}\t{}", note.id, first_line); + } + + Ok(()) +} + pub fn find_collection_path(profile: Option<&str>) -> Result { let home = dirs::home_dir().context("Could not find home directory")?; diff --git a/ankiview/src/util/mod.rs b/ankiview/src/util/mod.rs index 8ebfb66..527cd55 100644 --- a/ankiview/src/util/mod.rs +++ b/ankiview/src/util/mod.rs @@ -1 +1,2 @@ pub mod testing; +pub mod text; diff --git a/ankiview/src/util/text.rs b/ankiview/src/util/text.rs new file mode 100644 index 0000000..e54c148 --- /dev/null +++ b/ankiview/src/util/text.rs @@ -0,0 +1,92 @@ +// src/util/text.rs +use html_escape::decode_html_entities; +use regex::Regex; + +/// Extract the first line of plain text from HTML content. +/// +/// This function: +/// 1. Decodes HTML entities (e.g., & → &) +/// 2. Removes all HTML tags +/// 3. Extracts the first non-empty line +/// 4. Trims whitespace +/// +/// # Examples +/// +/// ``` +/// let html = "

What is a Tree?

Second line

"; +/// let first_line = extract_first_line(html); +/// assert_eq!(first_line, "What is a Tree?"); +/// ``` +pub fn extract_first_line(html: &str) -> String { + // Decode HTML entities first + let decoded = decode_html_entities(html).to_string(); + + // Replace block-level HTML tags with newlines to preserve line breaks + let block_re = Regex::new(r"]*>").unwrap(); + let with_newlines = block_re.replace_all(&decoded, "\n").into_owned(); + + // Remove all remaining HTML tags + let tag_re = Regex::new(r"<[^>]+>").unwrap(); + let no_tags = tag_re.replace_all(&with_newlines, "").into_owned(); + + // Split by newlines and find first non-empty line + no_tags + .lines() + .map(|line| line.trim()) + .find(|line| !line.is_empty()) + .unwrap_or("") + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn given_simple_html_when_extracting_first_line_then_returns_text_without_tags() { + let html = "

What is a Tree?

"; + assert_eq!(extract_first_line(html), "What is a Tree?"); + } + + #[test] + fn given_multiline_html_when_extracting_first_line_then_returns_only_first_line() { + let html = "

First line

Second line

"; + assert_eq!(extract_first_line(html), "First line"); + } + + #[test] + fn given_html_entities_when_extracting_first_line_then_decodes_entities() { + let html = "

Trees & Graphs

"; + assert_eq!(extract_first_line(html), "Trees & Graphs"); + } + + #[test] + fn given_nested_tags_when_extracting_first_line_then_removes_all_tags() { + let html = "
Bold and italic
"; + assert_eq!(extract_first_line(html), "Bold and italic"); + } + + #[test] + fn given_empty_html_when_extracting_first_line_then_returns_empty_string() { + let html = ""; + assert_eq!(extract_first_line(html), ""); + } + + #[test] + fn given_only_tags_when_extracting_first_line_then_returns_empty_string() { + let html = "

"; + assert_eq!(extract_first_line(html), ""); + } + + #[test] + fn given_whitespace_around_text_when_extracting_first_line_then_trims_whitespace() { + let html = "

What is a Tree?

"; + assert_eq!(extract_first_line(html), "What is a Tree?"); + } + + #[test] + fn given_line_breaks_in_html_when_extracting_first_line_then_handles_correctly() { + let html = "

\nWhat is a Tree?\n

Second

"; + assert_eq!(extract_first_line(html), "What is a Tree?"); + } +} diff --git a/ankiview/tests/test_anki.rs b/ankiview/tests/test_anki.rs index 0a7fd04..606e89f 100644 --- a/ankiview/tests/test_anki.rs +++ b/ankiview/tests/test_anki.rs @@ -142,3 +142,48 @@ fn given_repository_when_accessing_media_dir_then_returns_valid_path() -> Result assert!(media_dir.ends_with("collection.media")); Ok(()) } + +#[test] +fn given_collection_when_listing_all_notes_then_returns_all_notes() -> Result<()> { + // Arrange + let test_collection = TestCollection::new()?; + let mut repo = test_collection.open_repository()?; + + // Act + let notes = repo.list_notes(None)?; + + // Assert + assert!(notes.len() >= 10); // Test collection has at least 10 notes + assert!(notes.iter().any(|n| n.id == test_notes::TREE)); + assert!(notes.iter().any(|n| n.id == test_notes::DAG_NOTE)); + Ok(()) +} + +#[test] +fn given_collection_when_listing_with_search_then_returns_filtered_notes() -> Result<()> { + // Arrange + let test_collection = TestCollection::new()?; + let mut repo = test_collection.open_repository()?; + + // Act + let notes = repo.list_notes(Some("Tree"))?; + + // Assert + assert!(notes.len() > 0); + assert!(notes.iter().any(|n| n.front.contains("Tree"))); + Ok(()) +} + +#[test] +fn given_collection_when_searching_nonexistent_term_then_returns_empty() -> Result<()> { + // Arrange + let test_collection = TestCollection::new()?; + let mut repo = test_collection.open_repository()?; + + // Act + let notes = repo.list_notes(Some("xyznonexistent"))?; + + // Assert + assert_eq!(notes.len(), 0); + Ok(()) +} diff --git a/ankiview/tests/test_cli.rs b/ankiview/tests/test_cli.rs index 69ccbaf..90ecdc9 100644 --- a/ankiview/tests/test_cli.rs +++ b/ankiview/tests/test_cli.rs @@ -191,3 +191,55 @@ fn given_json_flag_with_global_flags_when_parsing_then_succeeds() { } assert_eq!(parsed.verbose, 1); } + +#[test] +fn given_list_command_without_search_when_parsing_then_succeeds() { + // Arrange + let args = vec!["ankiview", "list"]; + + // Act + let parsed = Args::try_parse_from(args).unwrap(); + + // Assert + match parsed.command { + Command::List { search } => { + assert_eq!(search, None); + } + _ => panic!("Expected List command"), + } +} + +#[test] +fn given_list_command_with_search_when_parsing_then_succeeds() { + // Arrange + let args = vec!["ankiview", "list", "tree"]; + + // Act + let parsed = Args::try_parse_from(args).unwrap(); + + // Assert + match parsed.command { + Command::List { search } => { + assert_eq!(search, Some("tree".to_string())); + } + _ => panic!("Expected List command"), + } +} + +#[test] +fn given_list_command_with_global_flags_when_parsing_then_succeeds() { + // Arrange + let args = vec!["ankiview", "-v", "list", "graph"]; + + // Act + let parsed = Args::try_parse_from(args).unwrap(); + + // Assert + match parsed.command { + Command::List { search } => { + assert_eq!(search, Some("graph".to_string())); + } + _ => panic!("Expected List command"), + } + assert_eq!(parsed.verbose, 1); +}