Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ankiview/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ankiview/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions ankiview/src/application/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions ankiview/src/application/note_deleter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ mod tests {
Err(DomainError::NoteNotFound(id))
}
}

fn list_notes(&mut self, _search_query: Option<&str>) -> Result<Vec<Note>, DomainError> {
unimplemented!("Not needed for deleter tests")
}
}

#[test]
Expand Down
123 changes: 123 additions & 0 deletions ankiview/src/application/note_lister.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// src/application/note_lister.rs
use crate::application::NoteRepository;
use crate::domain::{DomainError, Note};

pub struct NoteLister<R: NoteRepository> {
repository: R,
}

impl<R: NoteRepository> NoteLister<R> {
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<Vec<Note>, DomainError> {
self.repository.list_notes(search_query)
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::domain::Note;

struct MockRepository {
notes: Vec<Note>,
}

impl NoteRepository for MockRepository {
fn get_note(&mut self, id: i64) -> Result<Note, DomainError> {
self.notes
.iter()
.find(|n| n.id == id)
.cloned()
.ok_or(DomainError::NoteNotFound(id))
}

fn delete_note(&mut self, _id: i64) -> Result<usize, DomainError> {
unimplemented!()
}

fn list_notes(&mut self, search_query: Option<&str>) -> Result<Vec<Note>, 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);
}
}
5 changes: 5 additions & 0 deletions ankiview/src/application/note_viewer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize, DomainError>;

/// 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<Vec<Note>, DomainError>;
}

pub struct NoteViewer<R: NoteRepository> {
Expand Down
19 changes: 15 additions & 4 deletions ankiview/src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
Expand All @@ -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)]
Expand All @@ -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
Expand All @@ -38,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<String>,
},
}
4 changes: 3 additions & 1 deletion ankiview/src/domain/note.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
48 changes: 48 additions & 0 deletions ankiview/src/infrastructure/anki.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<Note>, DomainError> {
// Get note IDs based on search query
let note_ids: Vec<NoteId> = 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)
}
}
53 changes: 41 additions & 12 deletions ankiview/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,33 +30,42 @@ 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),
Command::List { search } => handle_list_command(search.as_deref(), 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(&note);
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(&note).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(&note);
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(())
}
Expand Down Expand Up @@ -84,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(&note.front);
println!("{}\t{}", note.id, first_line);
}

Ok(())
}

pub fn find_collection_path(profile: Option<&str>) -> Result<PathBuf> {
let home = dirs::home_dir().context("Could not find home directory")?;

Expand Down
1 change: 1 addition & 0 deletions ankiview/src/util/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod testing;
pub mod text;
Loading