From fadd93469c888efeb52538bbb8ace57c1acc1980 Mon Sep 17 00:00:00 2001 From: sysid Date: Sun, 12 Apr 2026 13:25:12 +0200 Subject: [PATCH 1/5] migration to rsenv --- .envrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.envrc b/.envrc index af0248a..6d75735 120000 --- a/.envrc +++ b/.envrc @@ -1 +1 @@ -../sec-sops/guarded/ankiview-eaa5973f/dot.envrc \ No newline at end of file +../../../../.rsenv/vaults/ankiview-eb3db696/dot.envrc \ No newline at end of file From e389c5e98b4e60ee7c5af2bc06356d9a8e6a61f0 Mon Sep 17 00:00:00 2001 From: sysid Date: Sun, 12 Apr 2026 16:38:40 +0200 Subject: [PATCH 2/5] feat: extend NoteRepository with tag and field update operations Add four new trait methods to NoteRepository (add_tags, remove_tags, update_note_fields_and_tags, replace_tag) with full implementations on AnkiRepository. replace_tag supports three modes: rename, bulk add (empty old), and bulk remove (empty new) with optional query filtering. Update MockNoteRepository to implement all new trait methods. --- .gitignore | 3 + ankiview/src/application/note_viewer.rs | 25 ++++ ankiview/src/infrastructure/anki.rs | 168 ++++++++++++++++++++++++ ankiview/src/util/testing.rs | 69 ++++++++++ 4 files changed, 265 insertions(+) diff --git a/.gitignore b/.gitignore index 20e68ce..34edcc0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ +specs/ +.workmux.yaml +.specify/ thoughts/ data/testuser/collection.anki2-wal diff --git a/ankiview/src/application/note_viewer.rs b/ankiview/src/application/note_viewer.rs index d374d91..27cbae5 100644 --- a/ankiview/src/application/note_viewer.rs +++ b/ankiview/src/application/note_viewer.rs @@ -17,6 +17,31 @@ pub trait NoteRepository { /// List all available note types (models) in the collection /// Returns a vector of (notetype_id, notetype_name) tuples fn list_notetypes(&mut self) -> Result, DomainError>; + + /// Add tags to an existing note (merge: existing tags preserved) + fn add_tags(&mut self, id: i64, tags: &[String]) -> Result<(), DomainError>; + + /// Remove specific tags from an existing note + fn remove_tags(&mut self, id: i64, tags: &[String]) -> Result<(), DomainError>; + + /// Update both fields and tags on an existing note + fn update_note_fields_and_tags( + &mut self, + id: i64, + fields: &[String], + tags: &[String], + ) -> Result<(), DomainError>; + + /// Replace a tag across notes matching an optional query. + /// If old_tag is empty: adds new_tag to all matching notes (bulk add). + /// If new_tag is empty: removes old_tag from all matching notes (bulk remove). + /// Returns the number of notes affected. + fn replace_tag( + &mut self, + query: Option<&str>, + old_tag: &str, + new_tag: &str, + ) -> Result; } pub struct NoteViewer { diff --git a/ankiview/src/infrastructure/anki.rs b/ankiview/src/infrastructure/anki.rs index d29c172..79e6395 100644 --- a/ankiview/src/infrastructure/anki.rs +++ b/ankiview/src/infrastructure/anki.rs @@ -346,6 +346,82 @@ impl AnkiRepository { } } +// --- Tag and field update helpers (used by NoteRepository trait impl) --- + +impl AnkiRepository { + /// Add tags to a note, merging with existing tags (no duplicates) + fn merge_tags_on_note(&mut self, note_id: i64, new_tags: &[String]) -> Result<()> { + let mut note = self + .collection + .storage + .get_note(NoteId(note_id)) + .context("Failed to get note from storage")? + .ok_or_else(|| anyhow::anyhow!("Note not found: {}", note_id))?; + + // Merge: add only tags not already present + for tag in new_tags { + if !note.tags.iter().any(|t| t == tag) { + note.tags.push(tag.clone()); + } + } + + self.collection + .update_note(&mut note) + .context("Failed to update note tags")?; + + debug!(note_id, tags_added = new_tags.len(), "Merged tags on note"); + Ok(()) + } + + /// Remove specific tags from a note + fn remove_tags_from_note(&mut self, note_id: i64, tags_to_remove: &[String]) -> Result<()> { + let mut note = self + .collection + .storage + .get_note(NoteId(note_id)) + .context("Failed to get note from storage")? + .ok_or_else(|| anyhow::anyhow!("Note not found: {}", note_id))?; + + note.tags.retain(|t| !tags_to_remove.contains(t)); + + self.collection + .update_note(&mut note) + .context("Failed to update note tags")?; + + debug!(note_id, "Removed tags from note"); + Ok(()) + } + + /// Update fields and tags on a note + fn update_fields_and_tags( + &mut self, + note_id: i64, + fields: &[String], + tags: &[String], + ) -> Result<()> { + let mut note = self + .collection + .storage + .get_note(NoteId(note_id)) + .context("Failed to get note from storage")? + .ok_or_else(|| anyhow::anyhow!("Note not found: {}", note_id))?; + + for (index, field_value) in fields.iter().enumerate() { + note.set_field(index, field_value) + .with_context(|| format!("Failed to set field {} on note {}", index, note_id))?; + } + + note.tags = tags.to_vec(); + + self.collection + .update_note(&mut note) + .context("Failed to update note")?; + + debug!(note_id, "Updated note fields and tags"); + Ok(()) + } +} + impl NoteRepository for AnkiRepository { #[instrument(level = "debug", skip(self))] fn get_note(&mut self, id: i64) -> Result { @@ -473,6 +549,98 @@ impl NoteRepository for AnkiRepository { Ok(notetypes) } + + #[instrument(level = "debug", skip(self))] + fn add_tags(&mut self, id: i64, tags: &[String]) -> Result<(), DomainError> { + self.merge_tags_on_note(id, tags) + .map_err(|e| DomainError::CollectionError(e.to_string())) + } + + #[instrument(level = "debug", skip(self))] + fn remove_tags(&mut self, id: i64, tags: &[String]) -> Result<(), DomainError> { + self.remove_tags_from_note(id, tags) + .map_err(|e| DomainError::CollectionError(e.to_string())) + } + + #[instrument(level = "debug", skip(self))] + fn update_note_fields_and_tags( + &mut self, + id: i64, + fields: &[String], + tags: &[String], + ) -> Result<(), DomainError> { + self.update_fields_and_tags(id, fields, tags) + .map_err(|e| DomainError::CollectionError(e.to_string())) + } + + #[instrument(level = "debug", skip(self))] + fn replace_tag( + &mut self, + query: Option<&str>, + old_tag: &str, + new_tag: &str, + ) -> Result { + use anki::search::SearchNode; + + // Get note IDs based on query + let note_ids = match query { + Some(q) if !q.is_empty() => self + .collection + .search_notes_unordered(q) + .map_err(|e| DomainError::CollectionError(e.to_string()))?, + _ => { + let search_node = SearchNode::WholeCollection; + self.collection + .search_notes_unordered(search_node) + .map_err(|e| DomainError::CollectionError(e.to_string()))? + } + }; + + let mut affected = 0; + + for note_id in note_ids { + let mut note = match self.collection.storage.get_note(note_id) { + Ok(Some(n)) => n, + _ => continue, + }; + + let had_old_tag = !old_tag.is_empty() && note.tags.iter().any(|t| t == old_tag); + let mut changed = false; + + if old_tag.is_empty() { + // Bulk add mode: add new_tag if not present + if !note.tags.iter().any(|t| t == new_tag) { + note.tags.push(new_tag.to_string()); + changed = true; + } + } else if new_tag.is_empty() { + // Bulk remove mode: remove old_tag + if had_old_tag { + note.tags.retain(|t| t != old_tag); + changed = true; + } + } else { + // Rename mode: replace old_tag with new_tag + if had_old_tag { + note.tags.retain(|t| t != old_tag); + if !note.tags.iter().any(|t| t == new_tag) { + note.tags.push(new_tag.to_string()); + } + changed = true; + } + } + + if changed { + self.collection + .update_note(&mut note) + .map_err(|e| DomainError::CollectionError(e.to_string()))?; + affected += 1; + } + } + + debug!(affected, old_tag, new_tag, "Tag replace completed"); + Ok(affected) + } } #[cfg(test)] diff --git a/ankiview/src/util/testing.rs b/ankiview/src/util/testing.rs index 7b30559..4161f23 100644 --- a/ankiview/src/util/testing.rs +++ b/ankiview/src/util/testing.rs @@ -95,6 +95,75 @@ impl NoteRepository for MockNoteRepository { fn list_notetypes(&mut self) -> Result, DomainError> { Ok(self.notetypes.clone()) } + + fn add_tags(&mut self, id: i64, tags: &[String]) -> Result<(), DomainError> { + let note = self.notes.get_mut(&id).ok_or(DomainError::NoteNotFound(id))?; + for tag in tags { + if !note.tags.contains(tag) { + note.tags.push(tag.clone()); + } + } + Ok(()) + } + + fn remove_tags(&mut self, id: i64, tags: &[String]) -> Result<(), DomainError> { + let note = self.notes.get_mut(&id).ok_or(DomainError::NoteNotFound(id))?; + note.tags.retain(|t| !tags.contains(t)); + Ok(()) + } + + fn update_note_fields_and_tags( + &mut self, + id: i64, + fields: &[String], + tags: &[String], + ) -> Result<(), DomainError> { + let note = self.notes.get_mut(&id).ok_or(DomainError::NoteNotFound(id))?; + if let Some(front) = fields.first() { + note.front = front.clone(); + } + if let Some(back) = fields.get(1) { + note.back = back.clone(); + } + note.tags = tags.to_vec(); + Ok(()) + } + + fn replace_tag( + &mut self, + _query: Option<&str>, + old_tag: &str, + new_tag: &str, + ) -> Result { + let mut affected = 0; + for note in self.notes.values_mut() { + let had_old = !old_tag.is_empty() && note.tags.contains(&old_tag.to_string()); + let mut changed = false; + + if old_tag.is_empty() { + if !note.tags.contains(&new_tag.to_string()) { + note.tags.push(new_tag.to_string()); + changed = true; + } + } else if new_tag.is_empty() { + if had_old { + note.tags.retain(|t| t != old_tag); + changed = true; + } + } else if had_old { + note.tags.retain(|t| t != old_tag); + if !note.tags.contains(&new_tag.to_string()) { + note.tags.push(new_tag.to_string()); + } + changed = true; + } + + if changed { + affected += 1; + } + } + Ok(affected) + } } /// Builder for MockNoteRepository From 6575631ea64745e4e2fe0118ec580d6968c42078 Mon Sep 17 00:00:00 2001 From: sysid Date: Sun, 12 Apr 2026 16:59:51 +0200 Subject: [PATCH 3/5] feat: add tag management, editor-based editing, and bulk tag operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New CLI commands: - `tag add/remove` for per-note tag management - `tag replace` with rename, bulk-add, bulk-remove modes and --query filter - `edit` opens $EDITOR with type-aware template (Basic→Front/Back, Cloze→Text/Extra) Collect now merges tags from markdown onto existing notes (additive only, never removes). This closes the gap where tag changes in markdown were ignored on re-import. Editor template follows bkmr's section-delimited pattern with validation against Anki invariants (empty sort field, missing cloze deletions). New application layer: NoteUpdater, TagManager, NoteEditor use cases. New infrastructure: NoteTemplate with rendering, parsing, and validation. --- README.md | 79 +++- ankiview/src/application/mod.rs | 6 + ankiview/src/application/note_editor.rs | 86 +++++ ankiview/src/application/note_updater.rs | 24 ++ ankiview/src/application/tag_manager.rs | 27 ++ ankiview/src/cli/args.rs | 61 +++ ankiview/src/infrastructure/anki.rs | 162 ++++++++ ankiview/src/infrastructure/mod.rs | 1 + ankiview/src/infrastructure/note_template.rs | 364 ++++++++++++++++++ .../src/inka/application/card_collector.rs | 9 + ankiview/src/lib.rs | 77 +++- ankiview/tests/test_note_updater.rs | 56 +++ ankiview/tests/test_tag_manager.rs | 81 ++++ 13 files changed, 1024 insertions(+), 9 deletions(-) create mode 100644 ankiview/src/application/note_editor.rs create mode 100644 ankiview/src/application/note_updater.rs create mode 100644 ankiview/src/application/tag_manager.rs create mode 100644 ankiview/src/infrastructure/note_template.rs create mode 100644 ankiview/tests/test_note_updater.rs create mode 100644 ankiview/tests/test_tag_manager.rs diff --git a/README.md b/README.md index 9dc1902..df0ac23 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,10 @@ flashcards — all without opening Anki. - **List notes** - Browse and search notes from the command line - **List card types** - See available card types in your collection - **Import markdown** - Convert markdown flashcards to Anki notes -- **Smart updates** - Automatically track cards with ID comments +- **Tag management** - Add, remove, or replace tags on notes via CLI +- **Edit notes** - Open any note in your `$EDITOR` with a type-aware template +- **Bulk tag operations** - Rename, bulk-add, or bulk-remove tags across notes +- **Smart updates** - Automatically track cards with ID comments; tags merged on re-import - **Media handling** - Import images from markdown files - **Hash caching** - Skip unchanged files for fast re-imports - **Custom card types** - Use any card type from your collection @@ -108,6 +111,52 @@ ankiview list-card-types This shows you which card types you can use with the `--card-type` flag. +### Manage tags + +Add or remove tags on individual notes: + +```bash +# Add tags to a note +ankiview tag add 1234567890 review urgent + +# Add a hierarchical tag +ankiview tag add 1234567890 "topic::math::algebra" + +# Remove tags from a note +ankiview tag remove 1234567890 review +``` + +Replace, bulk-add, or bulk-remove tags across the collection: + +```bash +# Rename a tag on all notes +ankiview tag replace --old "review" --new "reviewed" + +# Add a tag to all notes +ankiview tag replace --old "" --new "batch-2026" + +# Remove a tag from all notes +ankiview tag replace --old "obsolete" --new "" + +# Scope to specific notes using Anki search syntax +ankiview tag replace --old "review" --new "reviewed" --query "deck:Physics" +``` + +### Edit a note + +Open a note in your `$EDITOR` for full editing of fields and tags: + +```bash +ankiview edit 1234567890 +``` + +The editor opens a structured template adapted to the note type: +- **Basic notes** show Front/Back fields +- **Cloze notes** show Text/Extra fields +- **Custom note types** show their actual field names + +Fields are presented as raw HTML. Tags can be edited in the same session. The template is validated before saving (empty required fields and missing cloze deletions are rejected). + ### Collect markdown cards Import markdown flashcards into your Anki collection: @@ -167,8 +216,9 @@ Deck: ComputerScience 1. AnkiView reads your markdown files 2. Creates or updates notes in Anki -3. Injects ID comments into your markdown for tracking -4. Copies media files to Anki's collection.media/ +3. Merges tags from markdown onto existing notes (additive only — tags are never removed by `collect`) +4. Injects ID comments into your markdown for tracking +5. Copies media files to Anki's collection.media/ After the first run, your markdown will have ID comments: ```markdown @@ -247,11 +297,12 @@ The project structure: ``` src/ -├── application/ # Use cases and business logic -├── cli/ # Command-line interface -├── domain/ # Core domain models -├── infrastructure/ # External interfaces (Anki, browser) -└── ports/ # Input/output adapters +├── application/ # Use cases: NoteViewer, NoteUpdater, TagManager, NoteEditor, ... +├── cli/ # Command-line interface (clap) +├── domain/ # Core domain models (Note, DomainError) +├── infrastructure/ # Adapters: AnkiRepository, NoteTemplate, renderers +├── inka/ # Card collection subsystem (markdown → Anki) +└── ports/ # Output adapters (HtmlPresenter) ``` ### Running Tests @@ -298,6 +349,18 @@ RUST_LOG=debug cargo test - Use `-f` flag to force rebuild - Verify ID comments are correct and match Anki notes +7. **Tags not removed after editing markdown** (collect command) + - This is by design: `collect` only *adds* tags (merge-only semantics) + - Use `ankiview tag remove ` to remove tags + +8. **"Editor exited with non-zero status"** (edit command) + - Your editor quit abnormally; the edit was aborted + - Check the `EDITOR` environment variable is set correctly + +9. **"Cloze note Text field must contain at least one cloze deletion"** (edit command) + - Cloze notes require `{{c1::...}}` syntax in the Text field + - Add at least one cloze deletion before saving + ## Contributing 🤝 Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/ankiview/src/application/mod.rs b/ankiview/src/application/mod.rs index 7b2acea..46c5505 100644 --- a/ankiview/src/application/mod.rs +++ b/ankiview/src/application/mod.rs @@ -1,8 +1,14 @@ // src/application/mod.rs pub mod note_deleter; +pub mod note_editor; pub mod note_lister; +pub mod note_updater; pub mod note_viewer; +pub mod tag_manager; pub use note_deleter::NoteDeleter; +pub use note_editor::NoteEditor; pub use note_lister::NoteLister; +pub use note_updater::NoteUpdater; pub use note_viewer::{NoteRepository, NoteViewer}; +pub use tag_manager::TagManager; diff --git a/ankiview/src/application/note_editor.rs b/ankiview/src/application/note_editor.rs new file mode 100644 index 0000000..e0ce243 --- /dev/null +++ b/ankiview/src/application/note_editor.rs @@ -0,0 +1,86 @@ +// src/application/note_editor.rs +use crate::application::NoteRepository; +use crate::infrastructure::note_template::NoteTemplate; +use anyhow::{Context, Result}; +use std::fs; +use std::process::Command; +use tracing::{debug, info}; + +pub struct NoteEditor { + repository: R, +} + +impl NoteEditor { + pub fn new(repository: R) -> Self { + Self { repository } + } + + pub fn edit(&mut self, note_id: i64) -> Result { + // Fetch the note + let note = self + .repository + .get_note(note_id) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + // Build template from note + let template = NoteTemplate::from_note(¬e); + let template_text = template.to_string(); + + // Write to temp file + let temp_file = tempfile::Builder::new() + .suffix(".md") + .tempfile() + .context("Failed to create temporary file")?; + + fs::write(temp_file.path(), &template_text) + .context("Failed to write template to temp file")?; + + // Record modification time before editor + let modified_before = fs::metadata(temp_file.path())?.modified()?; + + // Open editor + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); + debug!(editor = %editor, path = ?temp_file.path(), "Opening editor"); + + let status = Command::new(&editor) + .arg(temp_file.path()) + .status() + .with_context(|| { + format!( + "Failed to open editor '{}'. Set the EDITOR environment variable.", + editor + ) + })?; + + if !status.success() { + return Err(anyhow::anyhow!( + "Editor exited with non-zero status (code {})", + status.code().unwrap_or(-1) + )); + } + + // Check if file was modified + let modified_after = fs::metadata(temp_file.path())?.modified()?; + if modified_after <= modified_before { + info!("No changes detected."); + return Ok(false); + } + + // Read edited content + let edited_text = + fs::read_to_string(temp_file.path()).context("Failed to read edited template")?; + + // Parse and validate + let edited_template = NoteTemplate::from_string(&edited_text, ¬e)?; + edited_template.validate(¬e)?; + + // Apply changes + let (fields, tags) = edited_template.to_update(); + self.repository + .update_note_fields_and_tags(note_id, &fields, &tags) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + info!(note_id, "Note updated successfully."); + Ok(true) + } +} diff --git a/ankiview/src/application/note_updater.rs b/ankiview/src/application/note_updater.rs new file mode 100644 index 0000000..a87b69d --- /dev/null +++ b/ankiview/src/application/note_updater.rs @@ -0,0 +1,24 @@ +// src/application/note_updater.rs +use crate::application::NoteRepository; +use crate::domain::DomainError; + +pub struct NoteUpdater { + repository: R, +} + +impl NoteUpdater { + pub fn new(repository: R) -> Self { + Self { repository } + } + + pub fn add_tags(&mut self, note_id: i64, tags: &[String]) -> Result<(), DomainError> { + // Verify note exists first for a clear error + self.repository.get_note(note_id)?; + self.repository.add_tags(note_id, tags) + } + + pub fn remove_tags(&mut self, note_id: i64, tags: &[String]) -> Result<(), DomainError> { + self.repository.get_note(note_id)?; + self.repository.remove_tags(note_id, tags) + } +} diff --git a/ankiview/src/application/tag_manager.rs b/ankiview/src/application/tag_manager.rs new file mode 100644 index 0000000..f515a28 --- /dev/null +++ b/ankiview/src/application/tag_manager.rs @@ -0,0 +1,27 @@ +// src/application/tag_manager.rs +use crate::application::NoteRepository; +use crate::domain::DomainError; + +pub struct TagManager { + repository: R, +} + +impl TagManager { + pub fn new(repository: R) -> Self { + Self { repository } + } + + pub fn replace_tag( + &mut self, + query: Option<&str>, + old_tag: &str, + new_tag: &str, + ) -> Result { + if old_tag.is_empty() && new_tag.is_empty() { + return Err(DomainError::CollectionError( + "Both --old and --new cannot be empty".to_string(), + )); + } + self.repository.replace_tag(query, old_tag, new_tag) + } +} diff --git a/ankiview/src/cli/args.rs b/ankiview/src/cli/args.rs index 38a7979..444fc64 100644 --- a/ankiview/src/cli/args.rs +++ b/ankiview/src/cli/args.rs @@ -102,4 +102,65 @@ pub enum Command { /// Displays all available note types that can be used with the --card-type flag. /// Each card type defines the fields and card templates for flashcards. ListCardTypes, + + /// Manage tags on notes + /// + /// Add, remove, or replace tags on individual notes or across the collection. + Tag { + #[command(subcommand)] + subcommand: TagCommand, + }, + + /// Edit a note in your $EDITOR + /// + /// Opens the note in a structured template showing all fields and tags. + /// The template adapts to the note type (Basic, Cloze, custom). + Edit { + /// Note ID to edit + #[arg(value_name = "NOTE_ID")] + note_id: i64, + }, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum TagCommand { + /// Add tags to a note + Add { + /// Note ID + #[arg(value_name = "NOTE_ID")] + note_id: i64, + + /// Tags to add + #[arg(value_name = "TAGS", required = true)] + tags: Vec, + }, + + /// Remove tags from a note + Remove { + /// Note ID + #[arg(value_name = "NOTE_ID")] + note_id: i64, + + /// Tags to remove + #[arg(value_name = "TAGS", required = true)] + tags: Vec, + }, + + /// Replace, bulk-add, or bulk-remove a tag across notes + /// + /// Modes: rename (both non-empty), bulk-add (--old ""), bulk-remove (--new ""). + /// Use --query to filter which notes are affected. + Replace { + /// Tag to match/remove (empty string for bulk-add mode) + #[arg(long)] + old: String, + + /// Tag to set/add (empty string for bulk-remove mode) + #[arg(long)] + new: String, + + /// Optional Anki search query to filter affected notes + #[arg(long)] + query: Option, + }, } diff --git a/ankiview/src/infrastructure/anki.rs b/ankiview/src/infrastructure/anki.rs index 79e6395..b21e7fc 100644 --- a/ankiview/src/infrastructure/anki.rs +++ b/ankiview/src/infrastructure/anki.rs @@ -904,4 +904,166 @@ mod tests { error_msg ); } + + // --- T009: Integration tests for add_tags and remove_tags --- + + #[test] + fn given_note_with_tags_when_adding_new_tag_then_merges() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + let note_id = repo + .create_basic_note("Q", "A", "Default", &["physics".to_string()], Some("Basic")) + .unwrap(); + + repo.add_tags(note_id, &["review".to_string()]).unwrap(); + + let note = repo.get_note(note_id).unwrap(); + assert!(note.tags.contains(&"physics".to_string())); + assert!(note.tags.contains(&"review".to_string())); + } + + #[test] + fn given_note_when_adding_duplicate_tag_then_no_duplicate() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + let note_id = repo + .create_basic_note("Q", "A", "Default", &["physics".to_string()], Some("Basic")) + .unwrap(); + + repo.add_tags(note_id, &["physics".to_string()]).unwrap(); + + let note = repo.get_note(note_id).unwrap(); + assert_eq!(note.tags.iter().filter(|t| *t == "physics").count(), 1); + } + + #[test] + fn given_note_when_adding_hierarchical_tag_then_stored_correctly() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + let note_id = repo + .create_basic_note("Q", "A", "Default", &[], Some("Basic")) + .unwrap(); + + repo.add_tags(note_id, &["topic::math::algebra".to_string()]) + .unwrap(); + + let note = repo.get_note(note_id).unwrap(); + assert!(note.tags.contains(&"topic::math::algebra".to_string())); + } + + #[test] + fn given_note_with_tags_when_removing_tag_then_removed() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + let note_id = repo + .create_basic_note( + "Q", + "A", + "Default", + &["physics".to_string(), "review".to_string()], + Some("Basic"), + ) + .unwrap(); + + repo.remove_tags(note_id, &["review".to_string()]).unwrap(); + + let note = repo.get_note(note_id).unwrap(); + assert!(note.tags.contains(&"physics".to_string())); + assert!(!note.tags.contains(&"review".to_string())); + } + + #[test] + fn given_note_when_removing_nonexistent_tag_then_no_error() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + let note_id = repo + .create_basic_note("Q", "A", "Default", &["physics".to_string()], Some("Basic")) + .unwrap(); + + // Should not error when removing a tag that doesn't exist + repo.remove_tags(note_id, &["nonexistent".to_string()]) + .unwrap(); + + let note = repo.get_note(note_id).unwrap(); + assert!(note.tags.contains(&"physics".to_string())); + } + + // --- T010: Integration tests for update_note_fields_and_tags --- + + #[test] + fn given_note_when_updating_fields_and_tags_then_both_change() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + let note_id = repo + .create_basic_note( + "Old Q", + "Old A", + "Default", + &["old-tag".to_string()], + Some("Basic"), + ) + .unwrap(); + + repo.update_note_fields_and_tags( + note_id, + &["New Q".to_string(), "New A".to_string()], + &["new-tag".to_string()], + ) + .unwrap(); + + let note = repo.get_note(note_id).unwrap(); + assert!(note.front.contains("New Q")); + assert!(note.back.contains("New A")); + assert!(note.tags.contains(&"new-tag".to_string())); + assert!(!note.tags.contains(&"old-tag".to_string())); + } + + // --- T011: Integration tests for replace_tag --- + + #[test] + fn given_notes_with_tag_when_replacing_then_renamed() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + let id1 = repo + .create_basic_note("Q1", "A1", "Default", &["review".to_string()], Some("Basic")) + .unwrap(); + let id2 = repo + .create_basic_note("Q2", "A2", "Default", &["review".to_string()], Some("Basic")) + .unwrap(); + + let affected = repo.replace_tag(None, "review", "reviewed").unwrap(); + + assert_eq!(affected, 2); + let n1 = repo.get_note(id1).unwrap(); + let n2 = repo.get_note(id2).unwrap(); + assert!(n1.tags.contains(&"reviewed".to_string())); + assert!(!n1.tags.contains(&"review".to_string())); + assert!(n2.tags.contains(&"reviewed".to_string())); + } + + #[test] + fn given_notes_when_bulk_adding_tag_then_all_get_tag() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + let id1 = repo + .create_basic_note("Q1", "A1", "Default", &[], Some("Basic")) + .unwrap(); + let id2 = repo + .create_basic_note("Q2", "A2", "Default", &[], Some("Basic")) + .unwrap(); + + let affected = repo.replace_tag(None, "", "batch-2026").unwrap(); + + assert_eq!(affected, 2); + assert!(repo.get_note(id1).unwrap().tags.contains(&"batch-2026".to_string())); + assert!(repo.get_note(id2).unwrap().tags.contains(&"batch-2026".to_string())); + } + + #[test] + fn given_notes_with_tag_when_bulk_removing_then_tag_gone() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + let id1 = repo + .create_basic_note("Q1", "A1", "Default", &["obsolete".to_string()], Some("Basic")) + .unwrap(); + let _id2 = repo + .create_basic_note("Q2", "A2", "Default", &[], Some("Basic")) + .unwrap(); + + let affected = repo.replace_tag(None, "obsolete", "").unwrap(); + + assert_eq!(affected, 1); + assert!(!repo.get_note(id1).unwrap().tags.contains(&"obsolete".to_string())); + } } diff --git a/ankiview/src/infrastructure/mod.rs b/ankiview/src/infrastructure/mod.rs index d689e27..9fbd601 100644 --- a/ankiview/src/infrastructure/mod.rs +++ b/ankiview/src/infrastructure/mod.rs @@ -1,5 +1,6 @@ // src/infrastructure/mod.rs pub mod anki; +pub mod note_template; pub mod renderer; pub use anki::AnkiRepository; diff --git a/ankiview/src/infrastructure/note_template.rs b/ankiview/src/infrastructure/note_template.rs new file mode 100644 index 0000000..043e758 --- /dev/null +++ b/ankiview/src/infrastructure/note_template.rs @@ -0,0 +1,364 @@ +// src/infrastructure/note_template.rs +// +// Editor template for note editing, following bkmr's section-delimited pattern. +// Renders a note to a structured template, parses it back, validates against Anki invariants. + +use crate::domain::Note; +use anyhow::{bail, Result}; +use regex::Regex; + +/// Represents a note opened for editing in the user's $EDITOR. +#[derive(Debug, Clone)] +pub struct NoteTemplate { + pub note_id: i64, + pub note_type_name: String, + pub field_names: Vec, + pub field_values: Vec, + pub tags: Vec, +} + +impl NoteTemplate { + /// Build a template from a domain Note. + /// Field names are inferred from the model name. + pub fn from_note(note: &Note) -> Self { + let (field_names, field_values) = infer_fields(note); + Self { + note_id: note.id, + note_type_name: note.model_name.clone(), + field_names, + field_values, + tags: note.tags.clone(), + } + } + + /// Render the template to a string for writing to a temp file. + pub fn to_string(&self) -> String { + let mut out = String::new(); + out.push_str(&format!( + "# Note {} ({})\n", + self.note_id, self.note_type_name + )); + out.push_str("# Lines starting with '#' are comments and will be ignored.\n"); + out.push_str("# Section markers (=== NAME ===) must not be removed.\n\n"); + + out.push_str("=== ID ===\n"); + out.push_str(&format!("{}\n\n", self.note_id)); + + out.push_str("=== NOTE TYPE ===\n"); + out.push_str(&format!("{}\n\n", self.note_type_name)); + + for (name, value) in self.field_names.iter().zip(self.field_values.iter()) { + out.push_str(&format!("=== {} ===\n", name.to_uppercase())); + out.push_str(value); + if !value.ends_with('\n') { + out.push('\n'); + } + out.push('\n'); + } + + out.push_str("=== TAGS ===\n"); + out.push_str(&self.tags.join(" ")); + out.push_str("\n\n"); + + out.push_str("=== END ===\n"); + out + } + + /// Parse an edited template string back into a NoteTemplate. + /// Validates structure and read-only fields against the original note. + pub fn from_string(text: &str, original: &Note) -> Result { + let sections = parse_sections(text)?; + + // Validate ID matches + let id_str = sections + .get("ID") + .ok_or_else(|| anyhow::anyhow!("Missing section marker: === ID ==="))? + .trim(); + let parsed_id: i64 = id_str + .parse() + .map_err(|_| anyhow::anyhow!("Invalid note ID in template: '{}'", id_str))?; + if parsed_id != original.id { + bail!( + "Note ID mismatch: template says {}, expected {}", + parsed_id, + original.id + ); + } + + // Validate note type matches + let note_type = sections + .get("NOTE TYPE") + .ok_or_else(|| anyhow::anyhow!("Missing section marker: === NOTE TYPE ==="))? + .trim(); + if note_type != original.model_name { + bail!("Note type cannot be changed via edit"); + } + + // Extract fields based on original note's field structure + let (field_names, _) = infer_fields(original); + let mut field_values = Vec::new(); + for name in &field_names { + let value = sections + .get(name.to_uppercase().as_str()) + .ok_or_else(|| { + anyhow::anyhow!("Missing section marker: === {} ===", name.to_uppercase()) + })?; + // Trim trailing newlines but preserve internal content + field_values.push(value.trim_end_matches('\n').to_string()); + } + + // Extract tags + let tags_str = sections.get("TAGS").map(|s| s.trim()).unwrap_or(""); + let tags: Vec = if tags_str.is_empty() { + vec![] + } else { + tags_str.split_whitespace().map(|s| s.to_string()).collect() + }; + + Ok(Self { + note_id: original.id, + note_type_name: original.model_name.clone(), + field_names, + field_values, + tags, + }) + } + + /// Validate the template against Anki invariants. + pub fn validate(&self, original: &Note) -> Result<()> { + // First field (sort field) cannot be empty + if let Some(first_value) = self.field_values.first() { + if first_value.trim().is_empty() { + let field_name = self.field_names.first().map(|s| s.as_str()).unwrap_or("First"); + bail!( + "Field '{}' cannot be empty — it is the sort field for this note type", + field_name + ); + } + } + + // Cloze notes must have cloze deletions in the Text field + let is_cloze = original.model_name.to_lowercase().contains("cloze"); + if is_cloze { + if let Some(text_value) = self.field_values.first() { + let cloze_re = Regex::new(r"\{\{c\d+::").unwrap(); + if !cloze_re.is_match(text_value) { + bail!( + "Cloze note Text field must contain at least one cloze deletion (e.g., {{{{c1::answer}}}})" + ); + } + } + } + + Ok(()) + } + + /// Convert to fields and tags for the repository update call. + pub fn to_update(&self) -> (Vec, Vec) { + (self.field_values.clone(), self.tags.clone()) + } +} + +/// Infer field names and values from a Note based on its model name. +fn infer_fields(note: &Note) -> (Vec, Vec) { + let model_lower = note.model_name.to_lowercase(); + if model_lower.contains("cloze") { + ( + vec!["Text".to_string(), "Extra".to_string()], + vec![note.front.clone(), note.back.clone()], + ) + } else { + ( + vec!["Front".to_string(), "Back".to_string()], + vec![note.front.clone(), note.back.clone()], + ) + } +} + +/// Parse section-delimited template into a map of section name → content. +fn parse_sections(text: &str) -> Result> { + let section_re = Regex::new(r"(?m)^===\s+(.+?)\s+===\s*$").unwrap(); + let mut sections = std::collections::HashMap::new(); + let matches: Vec<_> = section_re.find_iter(text).collect(); + + if matches.is_empty() { + bail!("Template structure invalid — no section markers found"); + } + + for i in 0..matches.len() { + let cap = section_re.captures(matches[i].as_str()).unwrap(); + let name = cap.get(1).unwrap().as_str(); + + if name == "END" { + break; + } + + let start = matches[i].end(); + let end = if i + 1 < matches.len() { + matches[i + 1].start() + } else { + text.len() + }; + + let content = &text[start..end]; + // Strip comment lines and leading/trailing newlines + let cleaned: String = content + .lines() + .filter(|line| !line.starts_with('#')) + .collect::>() + .join("\n"); + + sections.insert(name, cleaned.trim_start_matches('\n').to_string()); + } + + Ok(sections) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn basic_note() -> Note { + Note { + id: 12345, + front: "What is Rust?".to_string(), + back: "A systems programming language".to_string(), + tags: vec!["programming".to_string(), "rust".to_string()], + model_name: "Basic".to_string(), + } + } + + fn cloze_note() -> Note { + Note { + id: 67890, + front: "The capital of {{c1::France}} is {{c2::Paris}}".to_string(), + back: "Geography fact".to_string(), + tags: vec!["geography".to_string()], + model_name: "Cloze".to_string(), + } + } + + // T031: Renders correct template for Basic note + #[test] + fn given_basic_note_when_rendering_template_then_shows_front_back_tags() { + let note = basic_note(); + let template = NoteTemplate::from_note(¬e); + let text = template.to_string(); + + assert!(text.contains("=== FRONT ===")); + assert!(text.contains("=== BACK ===")); + assert!(text.contains("=== TAGS ===")); + assert!(text.contains("What is Rust?")); + assert!(text.contains("A systems programming language")); + assert!(text.contains("programming rust")); + assert!(text.contains("=== ID ===")); + assert!(text.contains("12345")); + assert!(text.contains("=== NOTE TYPE ===")); + assert!(text.contains("Basic")); + } + + // T032: Renders correct template for Cloze note + #[test] + fn given_cloze_note_when_rendering_template_then_shows_text_extra() { + let note = cloze_note(); + let template = NoteTemplate::from_note(¬e); + let text = template.to_string(); + + assert!(text.contains("=== TEXT ===")); + assert!(text.contains("=== EXTRA ===")); + assert!(text.contains("{{c1::France}}")); + assert!(text.contains("Geography fact")); + } + + // T033: Parses valid edited template back + #[test] + fn given_valid_template_when_parsing_then_returns_note_template() { + let note = basic_note(); + let template = NoteTemplate::from_note(¬e); + let text = template.to_string(); + + let parsed = NoteTemplate::from_string(&text, ¬e).unwrap(); + assert_eq!(parsed.note_id, 12345); + assert_eq!(parsed.field_values[0], "What is Rust?"); + assert_eq!(parsed.field_values[1], "A systems programming language"); + assert_eq!(parsed.tags, vec!["programming", "rust"]); + } + + // T034: Rejects template with missing section marker + #[test] + fn given_template_missing_section_when_parsing_then_error() { + let note = basic_note(); + let text = "=== ID ===\n12345\n\n=== NOTE TYPE ===\nBasic\n\n=== FRONT ===\nQ\n\n=== TAGS ===\ntag\n\n=== END ===\n"; + + let result = NoteTemplate::from_string(text, ¬e); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("BACK")); + } + + // T035: Rejects template with empty required field + #[test] + fn given_template_with_empty_front_when_validating_then_error() { + let note = basic_note(); + let template = NoteTemplate { + note_id: 12345, + note_type_name: "Basic".to_string(), + field_names: vec!["Front".to_string(), "Back".to_string()], + field_values: vec!["".to_string(), "Answer".to_string()], + tags: vec![], + }; + + let result = template.validate(¬e); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("cannot be empty")); + } + + // T036: Rejects Cloze without cloze deletion + #[test] + fn given_cloze_template_without_cloze_marker_when_validating_then_error() { + let note = cloze_note(); + let template = NoteTemplate { + note_id: 67890, + note_type_name: "Cloze".to_string(), + field_names: vec!["Text".to_string(), "Extra".to_string()], + field_values: vec!["No cloze here".to_string(), "Extra".to_string()], + tags: vec![], + }; + + let result = template.validate(¬e); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("cloze deletion")); + } + + // T037: Preserves raw HTML + #[test] + fn given_html_content_when_roundtripping_then_preserved() { + let note = Note { + id: 111, + front: "Bold and italic".to_string(), + back: "
Answer
".to_string(), + tags: vec![], + model_name: "Basic".to_string(), + }; + + let template = NoteTemplate::from_note(¬e); + let text = template.to_string(); + let parsed = NoteTemplate::from_string(&text, ¬e).unwrap(); + + assert_eq!(parsed.field_values[0], "Bold and italic"); + assert_eq!(parsed.field_values[1], "
Answer
"); + } + + // T038: Rejects note ID mismatch + #[test] + fn given_template_with_wrong_id_when_parsing_then_error() { + let note = basic_note(); + let text = "=== ID ===\n99999\n\n=== NOTE TYPE ===\nBasic\n\n=== FRONT ===\nQ\n\n=== BACK ===\nA\n\n=== TAGS ===\ntag\n\n=== END ===\n"; + + let result = NoteTemplate::from_string(&text, ¬e); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("mismatch")); + } +} diff --git a/ankiview/src/inka/application/card_collector.rs b/ankiview/src/inka/application/card_collector.rs index ed47003..4e6b91c 100644 --- a/ankiview/src/inka/application/card_collector.rs +++ b/ankiview/src/inka/application/card_collector.rs @@ -1,3 +1,4 @@ +use crate::application::NoteRepository; use crate::infrastructure::anki::AnkiRepository; use crate::inka::infrastructure::file_writer; use crate::inka::infrastructure::hasher::HashCache; @@ -143,6 +144,10 @@ impl CardCollector { if self.repository.note_exists(id)? { // Update existing note self.repository.update_note(id, &fields_html)?; + // Merge tags from markdown (additive only, never removes) + if !tags.is_empty() { + self.repository.add_tags(id, tags)?; + } id } else { // Note was deleted - create new note and replace ID @@ -170,6 +175,10 @@ impl CardCollector { content = file_writer::inject_anki_id(&content, note_str, id); // Update the existing note with current content self.repository.update_note(id, &fields_html)?; + // Merge tags from markdown (additive only, never removes) + if !tags.is_empty() { + self.repository.add_tags(id, tags)?; + } id } else { // No match found, create new note diff --git a/ankiview/src/lib.rs b/ankiview/src/lib.rs index 21de78b..565229a 100644 --- a/ankiview/src/lib.rs +++ b/ankiview/src/lib.rs @@ -25,7 +25,7 @@ pub mod ports; pub mod util; use crate::application::NoteRepository; -use crate::cli::args::{Args, Command}; +use crate::cli::args::{Args, Command, TagCommand}; use anyhow::{Context, Result}; use infrastructure::AnkiRepository; use ports::HtmlPresenter; @@ -71,6 +71,8 @@ pub fn run(args: Args) -> Result<()> { handle_collect_command(path, recursive, config, collection_path) } Command::ListCardTypes => handle_list_card_types_command(collection_path), + Command::Tag { subcommand } => handle_tag_command(subcommand, collection_path), + Command::Edit { note_id } => handle_edit_command(note_id, collection_path), } } @@ -243,6 +245,79 @@ fn handle_collect_command( Ok(()) } +fn handle_tag_command(subcommand: TagCommand, collection_path: PathBuf) -> Result<()> { + match subcommand { + TagCommand::Add { note_id, tags } => { + let repository = AnkiRepository::new(&collection_path)?; + let mut updater = application::NoteUpdater::new(repository); + + info!(note_id, ?tags, "Adding tags"); + updater + .add_tags(note_id, &tags) + .with_context(|| format!("Failed to add tags to note {}", note_id))?; + + println!("Added {} tag(s) to note {}.", tags.len(), note_id); + Ok(()) + } + TagCommand::Remove { note_id, tags } => { + let repository = AnkiRepository::new(&collection_path)?; + let mut updater = application::NoteUpdater::new(repository); + + info!(note_id, ?tags, "Removing tags"); + updater + .remove_tags(note_id, &tags) + .with_context(|| format!("Failed to remove tags from note {}", note_id))?; + + println!("Removed {} tag(s) from note {}.", tags.len(), note_id); + Ok(()) + } + TagCommand::Replace { old, new, query } => { + if old.is_empty() && new.is_empty() { + return Err(anyhow::anyhow!( + "Both --old and --new cannot be empty." + )); + } + + let repository = AnkiRepository::new(&collection_path)?; + let mut manager = application::TagManager::new(repository); + + info!(old_tag = %old, new_tag = %new, ?query, "Replacing tags"); + let affected = manager + .replace_tag(query.as_deref(), &old, &new) + .with_context(|| "Failed to replace tags")?; + + // Format output based on mode + if old.is_empty() { + println!("Added tag '{}' to {} note(s).", new, affected); + } else if new.is_empty() { + println!("Removed tag '{}' from {} note(s).", old, affected); + } else { + println!( + "Replaced tag '{}' → '{}' on {} note(s).", + old, new, affected + ); + } + Ok(()) + } + } +} + +fn handle_edit_command(note_id: i64, collection_path: PathBuf) -> Result<()> { + let repository = AnkiRepository::new(&collection_path)?; + let mut editor = application::NoteEditor::new(repository); + + info!(note_id, "Editing note"); + let changed = editor.edit(note_id)?; + + if changed { + println!("Note {} updated successfully.", note_id); + } else { + println!("No changes detected."); + } + + Ok(()) +} + /// Find the Anki collection path for a given profile. /// /// This function contains platform-specific logic for locating Anki's data directory. diff --git a/ankiview/tests/test_note_updater.rs b/ankiview/tests/test_note_updater.rs new file mode 100644 index 0000000..74fcdc5 --- /dev/null +++ b/ankiview/tests/test_note_updater.rs @@ -0,0 +1,56 @@ +// tests/test_note_updater.rs — US2 tests for NoteUpdater use case +use ankiview::application::NoteUpdater; +use ankiview::domain::Note; +use ankiview::util::testing::MockNoteRepository; + +fn note_with_tags(id: i64, tags: Vec) -> Note { + Note { + id, + front: "Q".to_string(), + back: "A".to_string(), + tags, + model_name: "Basic".to_string(), + } +} + +// T021: tag add adds tag to existing note +#[test] +fn given_existing_note_when_adding_tag_then_tag_is_added() { + let note = note_with_tags(123, vec!["physics".to_string()]); + let repo = MockNoteRepository::builder().with_note(123, note).build(); + let mut updater = NoteUpdater::new(repo); + + updater.add_tags(123, &["review".to_string()]).unwrap(); +} + +// T022: tag remove removes tag from existing note +#[test] +fn given_existing_note_when_removing_tag_then_tag_is_removed() { + let note = note_with_tags(123, vec!["physics".to_string(), "review".to_string()]); + let repo = MockNoteRepository::builder().with_note(123, note).build(); + let mut updater = NoteUpdater::new(repo); + + updater.remove_tags(123, &["review".to_string()]).unwrap(); +} + +// T023: tag add on nonexistent note returns error +#[test] +fn given_nonexistent_note_when_adding_tag_then_error() { + let repo = MockNoteRepository::builder().build(); + let mut updater = NoteUpdater::new(repo); + + let result = updater.add_tags(99999, &["review".to_string()]); + assert!(result.is_err()); +} + +// T024: tag add with hierarchical tag works +#[test] +fn given_existing_note_when_adding_hierarchical_tag_then_stored() { + let note = note_with_tags(123, vec![]); + let repo = MockNoteRepository::builder().with_note(123, note).build(); + let mut updater = NoteUpdater::new(repo); + + updater + .add_tags(123, &["topic::math::algebra".to_string()]) + .unwrap(); +} diff --git a/ankiview/tests/test_tag_manager.rs b/ankiview/tests/test_tag_manager.rs new file mode 100644 index 0000000..a3d2ee6 --- /dev/null +++ b/ankiview/tests/test_tag_manager.rs @@ -0,0 +1,81 @@ +// tests/test_tag_manager.rs — US4 tests for TagManager use case +use ankiview::application::TagManager; +use ankiview::domain::Note; +use ankiview::util::testing::MockNoteRepository; + +fn note_with_tags(id: i64, tags: Vec) -> Note { + Note { + id, + front: format!("Q{}", id), + back: format!("A{}", id), + tags, + model_name: "Basic".to_string(), + } +} + +// T049: rename mode +#[test] +fn given_notes_with_tag_when_replacing_then_renamed() { + let repo = MockNoteRepository::builder() + .with_note(100, note_with_tags(100, vec!["review".to_string()])) + .with_note(200, note_with_tags(200, vec!["review".to_string()])) + .build(); + let mut manager = TagManager::new(repo); + + let affected = manager.replace_tag(None, "review", "reviewed").unwrap(); + assert_eq!(affected, 2); +} + +// T050: bulk add mode +#[test] +fn given_notes_when_bulk_adding_then_all_get_tag() { + let repo = MockNoteRepository::builder() + .with_note(100, note_with_tags(100, vec![])) + .with_note(200, note_with_tags(200, vec![])) + .build(); + let mut manager = TagManager::new(repo); + + let affected = manager.replace_tag(None, "", "batch-2026").unwrap(); + assert_eq!(affected, 2); +} + +// T051: bulk remove mode +#[test] +fn given_notes_with_tag_when_bulk_removing_then_tag_removed() { + let repo = MockNoteRepository::builder() + .with_note(100, note_with_tags(100, vec!["obsolete".to_string()])) + .with_note(200, note_with_tags(200, vec![])) + .build(); + let mut manager = TagManager::new(repo); + + let affected = manager.replace_tag(None, "obsolete", "").unwrap(); + assert_eq!(affected, 1); +} + +// T053: both empty returns validation error +#[test] +fn given_both_empty_when_replacing_then_error() { + let repo = MockNoteRepository::builder().build(); + let mut manager = TagManager::new(repo); + + let result = manager.replace_tag(None, "", ""); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("cannot be empty")); +} + +// T054: reports correct affected count +#[test] +fn given_three_notes_one_matching_when_replacing_then_reports_one() { + let repo = MockNoteRepository::builder() + .with_note(100, note_with_tags(100, vec!["target".to_string()])) + .with_note(200, note_with_tags(200, vec!["other".to_string()])) + .with_note(300, note_with_tags(300, vec!["other".to_string()])) + .build(); + let mut manager = TagManager::new(repo); + + let affected = manager.replace_tag(None, "target", "replaced").unwrap(); + assert_eq!(affected, 1); +} From 926a274d23ecd1f68c7c670c028424c686eb514c Mon Sep 17 00:00:00 2001 From: sysid Date: Sun, 12 Apr 2026 17:12:37 +0200 Subject: [PATCH 4/5] feat: Makefile upgrade --- Makefile | 85 ++++++++++++++++++-------------------------------------- 1 file changed, 27 insertions(+), 58 deletions(-) diff --git a/Makefile b/Makefile index 45192c1..947d3bb 100644 --- a/Makefile +++ b/Makefile @@ -18,17 +18,6 @@ BINARY = ankiview # Makefile directory CODE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) -# define files -MANS = $(wildcard ./*.md) -MAN_HTML = $(MANS:.md=.html) -MAN_PAGES = $(MANS:.md=.1) -# avoid circular targets -MAN_BINS = $(filter-out ./tw-extras.md, $(MANS)) - -.PHONY: all -all: clean build-fast install-debug ## all - : - ################################################################################ # Admin \ ADMIN:: ## ################################################################## @@ -59,7 +48,7 @@ anki: ## anki open /Applications/Anki.app --args -b $(HOME)/xxx/ankiview-test .PHONY: test -test: ## Run all tests (unit, integration, and doc tests) with debug logging +test: ## tests, single-threaded (all functionality) pushd $(pkg_src) && RUST_LOG=INFO cargo test --all-features --all-targets -- --test-threads=1 #--nocapture .PHONY: refresh-test-fixture @@ -75,6 +64,14 @@ test-verbose: ## Run tests with verbose logging # Building, Deploying \ BUILDING: ## ################################################################## +.PHONY: all +all: clean build install ## all + : + +.PHONY: all-fast +all-fast: clean build-fast install-debug ## all-debug: debug build + : + .PHONY: doc doc: ## doc @rustup doc --std @@ -82,38 +79,39 @@ doc: ## doc .PHONY: upload upload: ## upload - @echo "anki not on crate.io, so cannt publish" - #@if [ -z "$$CARGO_REGISTRY_TOKEN" ]; then \ - # echo "Error: CARGO_REGISTRY_TOKEN is not set"; \ - # exit 1; \ - #fi - #@echo "CARGO_REGISTRY_TOKEN is set" - #pushd $(pkg_src) && cargo release publish --execute + @echo "anki not on crate.io, so cannot publish" .PHONY: build -build: ## build +build: ## build release version pushd $(pkg_src) && cargo build --release .PHONY: build-fast build-fast: ## build debug version pushd $(pkg_src) && cargo build +# macOS Code Signing Fix: +# When Rust's linker builds a binary, it creates an adhoc linker-signed signature. +# When copied with `cp`, macOS preserves this signature but it becomes invalid +# because the hash was computed for the original path/inode. macOS AMFI (Apple +# Mobile File Integrity) detects the mismatch and kills the process with SIGKILL +# (signal 9, exit code 137). Re-signing with `codesign --force --sign -` creates +# a fresh adhoc signature valid for the new location. + .PHONY: install-debug install-debug: uninstall ## install-debug (no release version) @VERSION=$(shell cat VERSION) && \ echo "-M- Installing $$VERSION" && \ cp -vf ankiview/target/debug/$(BINARY) ~/bin/$(BINARY)$$VERSION && \ + codesign --force --sign - ~/bin/$(BINARY)$$VERSION && \ ln -vsf ~/bin/$(BINARY)$$VERSION ~/bin/$(BINARY) # ~/bin/$(BINARY) completion bash > ~/.bash_completions/ankiview -#.PHONY: install -#install: uninstall ## install - #@cp -vf $(pkg_src)/target/release/$(BINARY) ~/bin/$(BINARY) .PHONY: install install: uninstall ## install @VERSION=$(shell cat VERSION) && \ - echo "-M- Installagin $$VERSION" && \ + echo "-M- Installing $$VERSION" && \ cp -vf ankiview/target/release/$(BINARY) ~/bin/$(BINARY)$$VERSION && \ + codesign --force --sign - ~/bin/$(BINARY)$$VERSION && \ ln -vsf ~/bin/$(BINARY)$$VERSION ~/bin/$(BINARY) .PHONY: uninstall @@ -159,7 +157,6 @@ check-github-token: ## Check if GITHUB_TOKEN is set exit 1; \ fi @echo "GITHUB_TOKEN is set" - #@$(MAKE) fix-version # not working: rustrover deleay .PHONY: fix-version fix-version: check-github-token ## fix-version of Cargo.toml, re-connect with HEAD @@ -169,13 +166,14 @@ fix-version: check-github-token ## fix-version of Cargo.toml, re-connect with H git push --force-with-lease git push --tags --force -.PHONY: style -style: ## style +.PHONY: format +format: ## format pushd $(pkg_src) && cargo fmt .PHONY: lint -lint: ## lint - pushd $(pkg_src) && cargo clippy +lint: ## lint and fix + pushd $(pkg_src) && cargo clippy --fix -- -A unused_imports + pushd $(pkg_src) && cargo fix --lib -p ankiview --tests ################################################################################ @@ -186,21 +184,6 @@ CLEAN: ## ############################################################ clean:clean-rs ## clean all : -.PHONY: clean-build -clean-build: ## remove build artifacts - rm -fr build/ - rm -fr dist/ - rm -fr .eggs/ - find . \( -path ./env -o -path ./venv -o -path ./.env -o -path ./.venv \) -prune -o -name '*.egg-info' -exec rm -fr {} + - find . \( -path ./env -o -path ./venv -o -path ./.env -o -path ./.venv \) -prune -o -name '*.egg' -exec rm -f {} + - -.PHONY: clean-pyc -clean-pyc: ## remove Python file artifacts - find . -name '*.pyc' -exec rm -f {} + - find . -name '*.pyo' -exec rm -f {} + - find . -name '*~' -exec rm -f {} + - find . -name '__pycache__' -exec rm -fr {} + - .PHONY: clean-rs clean-rs: ## clean-rs pushd $(pkg_src) && cargo clean -v @@ -227,17 +210,3 @@ help: debug: ## debug @echo "-D- CODE_DIR: $(CODE_DIR)" - - -.PHONY: list -list: * ## list - @echo $^ - -.PHONY: list2 -%: %.md ## list2 - @echo $^ - - -%-plan: ## call with: make -plan - @echo $@ : $* - @echo $@ : $^ From 032a4ba07e8d9a2a910b655944b3ae0a2c4cba84 Mon Sep 17 00:00:00 2001 From: sysid Date: Sun, 12 Apr 2026 19:54:49 +0200 Subject: [PATCH 5/5] manual testing --- .gitignore | 3 - Makefile | 4 + scripts/manual-test.sh | 362 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 366 insertions(+), 3 deletions(-) create mode 100755 scripts/manual-test.sh diff --git a/.gitignore b/.gitignore index 34edcc0..20e68ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,2 @@ -specs/ -.workmux.yaml -.specify/ thoughts/ data/testuser/collection.anki2-wal diff --git a/Makefile b/Makefile index 947d3bb..ff6577f 100644 --- a/Makefile +++ b/Makefile @@ -47,6 +47,10 @@ anki: ## anki # specify base folder with -b open /Applications/Anki.app --args -b $(HOME)/xxx/ankiview-test +.PHONY: manual-test +manual-test: build-fast init-env ## manual test (collection in ~/xxx), make manual-test FILTER=tag + ./scripts/manual-test.sh $(FILTER) + .PHONY: test test: ## tests, single-threaded (all functionality) pushd $(pkg_src) && RUST_LOG=INFO cargo test --all-features --all-targets -- --test-threads=1 #--nocapture diff --git a/scripts/manual-test.sh b/scripts/manual-test.sh new file mode 100755 index 0000000..b65cb40 --- /dev/null +++ b/scripts/manual-test.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +# Manual test script for ankiview card update features. +# Uses an isolated Anki collection in ~/xxx/ankiview-test/ — never touches production. +# +# Prerequisites: +# - Anki must NOT be running +# - ankiview must be built: make build-fast +# - Test fixture must exist: make init-env +# +# Usage: +# ./scripts/manual-test.sh # run all tests +# ./scripts/manual-test.sh collect # run only collect tag-merge tests +# ./scripts/manual-test.sh tag # run only tag add/remove/replace tests +# ./scripts/manual-test.sh edit # run only edit tests (opens $EDITOR) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +ANKIVIEW="${PROJECT_ROOT}/ankiview/target/debug/ankiview" +TEST_BASE="${HOME}/xxx/ankiview-test" +COLLECTION="${TEST_BASE}/User 1/collection.anki2" +SAMPLE_MD="${PROJECT_ROOT}/ankiview/examples/sample-notes.md" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +passed=0 +failed=0 +skipped=0 + +# ── Helpers ────────────────────────────────────────────────────────────────── + +die() { echo -e "${RED}FATAL: $*${NC}" >&2; exit 1; } +info() { echo -e "${CYAN}▶ $*${NC}"; } +pass() { echo -e " ${GREEN}✓ $*${NC}"; ((passed++)); } +fail() { echo -e " ${RED}✗ $*${NC}"; ((failed++)); } +skip() { echo -e " ${YELLOW}⊘ $*${NC}"; ((skipped++)); } +sep() { echo "────────────────────────────────────────────────────────────"; } + +run_ankiview() { + "$ANKIVIEW" -c "$COLLECTION" "$@" +} + +# Reset test environment: fresh copy of fixture collection +reset_env() { + info "Resetting test environment → ${TEST_BASE}" + rm -rf "${TEST_BASE}" + mkdir -p "${TEST_BASE}" + cp -r "${PROJECT_ROOT}/ankiview/tests/fixtures/test_collection/"* "${TEST_BASE}/" + + # Reset sample-notes.md to original (strip any injected IDs) + if [[ -f "${SAMPLE_MD}.ori" ]]; then + cp -f "${SAMPLE_MD}.ori" "$SAMPLE_MD" + fi +} + +# ── Preflight ──────────────────────────────────────────────────────────────── + +preflight() { + [[ -f "$ANKIVIEW" ]] || die "Binary not found. Run: make build-fast" + + if pgrep -q -i anki 2>/dev/null; then + die "Anki is running. Close it first." + fi + + reset_env + info "Preflight OK" + sep +} + +# ── Test: collect with tag merge ───────────────────────────────────────────── + +test_collect() { + info "TEST GROUP: collect tag merge" + + # Create a temp markdown file with tags + local md_file + md_file=$(mktemp "${TEST_BASE}/test-tags-XXXXXX.md") + cat > "$md_file" <<'EOF' +--- +Deck: Default +Tags: physics quantum + +1. What is superposition? +> A particle existing in multiple states simultaneously +--- +EOF + + # First collect: creates the note + local output + output=$(run_ankiview collect "$md_file" 2>&1) || { fail "collect (create) failed: $output"; return; } + pass "collect created note" + + # Verify ID was injected + if grep -q '' "$md_file" | grep -o '[0-9]*') + info " Created note ID: $note_id" + + # Verify tags via view --json + local json + json=$(run_ankiview view "$note_id" --json 2>&1) + if echo "$json" | grep -q '"physics"'; then + pass "initial tags present (physics)" + else + fail "initial tags missing: $json" + fi + + # Modify tags in markdown (add 'review') + sed -i '' 's/Tags: physics quantum/Tags: physics quantum review/' "$md_file" + + # Re-collect: should merge tags + output=$(run_ankiview collect "$md_file" -f 2>&1) || { fail "collect (update) failed: $output"; return; } + pass "collect updated note" + + # Verify tags merged + json=$(run_ankiview view "$note_id" --json 2>&1) + if echo "$json" | grep -q '"review"'; then + pass "tag 'review' merged after collect" + else + fail "tag 'review' NOT merged: $json" + fi + + # Verify merge-only: remove tag from markdown, re-collect — tag should persist + sed -i '' 's/Tags: physics quantum review/Tags: physics/' "$md_file" + output=$(run_ankiview collect "$md_file" -f 2>&1) + json=$(run_ankiview view "$note_id" --json 2>&1) + if echo "$json" | grep -q '"review"'; then + pass "merge-only: 'review' preserved after markdown removal" + else + fail "merge-only violated: 'review' was removed" + fi + + rm -f "$md_file" + sep +} + +# ── Test: tag add / remove ─────────────────────────────────────────────────── + +test_tag() { + info "TEST GROUP: tag add / remove / replace" + + # First create a note to work with + local md_file + md_file=$(mktemp "${TEST_BASE}/test-tagcli-XXXXXX.md") + cat > "$md_file" <<'EOF' +--- +Deck: Default +Tags: baseline + +1. CLI tag test question +> CLI tag test answer +--- +EOF + + run_ankiview collect "$md_file" >/dev/null 2>&1 + local note_id + note_id=$(grep -o '' "$md_file" | grep -o '[0-9]*') + [[ -n "$note_id" ]] || { fail "failed to create test note"; return; } + info " Working with note ID: $note_id" + + # tag add + local output + output=$(run_ankiview tag add "$note_id" "urgent" 2>&1) + if echo "$output" | grep -qi "added"; then + pass "tag add reported success" + else + fail "tag add output unexpected: $output" + fi + + local json + json=$(run_ankiview view "$note_id" --json 2>&1) + if echo "$json" | grep -q '"urgent"'; then + pass "tag 'urgent' visible after add" + else + fail "tag 'urgent' not visible: $json" + fi + + # tag add hierarchical + output=$(run_ankiview tag add "$note_id" "topic::math::algebra" 2>&1) + json=$(run_ankiview view "$note_id" --json 2>&1) + if echo "$json" | grep -q 'topic::math::algebra'; then + pass "hierarchical tag added" + else + fail "hierarchical tag not visible: $json" + fi + + # tag remove + output=$(run_ankiview tag remove "$note_id" "urgent" 2>&1) + json=$(run_ankiview view "$note_id" --json 2>&1) + if echo "$json" | grep -q '"urgent"'; then + fail "tag 'urgent' still present after remove" + else + pass "tag 'urgent' removed" + fi + + # tag add on nonexistent note + output=$(run_ankiview tag add 99999999999 "test" 2>&1) && { + fail "tag add on nonexistent note should fail" + } || { + pass "tag add on nonexistent note → error" + } + + # tag replace: create two more notes + local md2 md3 + md2=$(mktemp "${TEST_BASE}/test-replace2-XXXXXX.md") + md3=$(mktemp "${TEST_BASE}/test-replace3-XXXXXX.md") + cat > "$md2" <<'EOF' +--- +Deck: Default +Tags: old-tag + +1. Replace test note 2 +> Answer 2 +--- +EOF + cat > "$md3" <<'EOF' +--- +Deck: Default +Tags: old-tag + +1. Replace test note 3 +> Answer 3 +--- +EOF + run_ankiview collect "$md2" >/dev/null 2>&1 + run_ankiview collect "$md3" >/dev/null 2>&1 + + # tag replace (rename) + output=$(run_ankiview tag replace --old "old-tag" --new "new-tag" 2>&1) + if echo "$output" | grep -q "2 note"; then + pass "tag replace renamed on 2 notes" + else + fail "tag replace output unexpected: $output" + fi + + # tag replace: bulk add + output=$(run_ankiview tag replace --old "" --new "batch-2026" 2>&1) + if echo "$output" | grep -qi "added.*note"; then + pass "tag replace bulk-add worked" + else + fail "tag replace bulk-add output: $output" + fi + + # tag replace: bulk remove + output=$(run_ankiview tag replace --old "batch-2026" --new "" 2>&1) + if echo "$output" | grep -qi "removed.*note"; then + pass "tag replace bulk-remove worked" + else + fail "tag replace bulk-remove output: $output" + fi + + # tag replace: both empty → error + output=$(run_ankiview tag replace --old "" --new "" 2>&1) && { + fail "tag replace both-empty should fail" + } || { + pass "tag replace both-empty → error" + } + + rm -f "$md_file" "$md2" "$md3" + sep +} + +# ── Test: edit ─────────────────────────────────────────────────────────────── + +test_edit() { + info "TEST GROUP: edit (interactive — opens \$EDITOR)" + + # Create a note + local md_file + md_file=$(mktemp "${TEST_BASE}/test-edit-XXXXXX.md") + cat > "$md_file" <<'EOF' +--- +Deck: Default +Tags: editable + +1. Edit test question +> Edit test answer +--- +EOF + run_ankiview collect "$md_file" >/dev/null 2>&1 + local note_id + note_id=$(grep -o '' "$md_file" | grep -o '[0-9]*') + [[ -n "$note_id" ]] || { fail "failed to create test note for edit"; return; } + + info " Note ID: $note_id" + info " This test is interactive — it will open your \$EDITOR." + info " Change the Back field, save and quit to verify update." + info " Or quit without saving to test no-change detection." + echo "" + + run_ankiview edit "$note_id" + local exit_code=$? + + if [[ $exit_code -eq 0 ]]; then + pass "edit command completed (exit 0)" + info " Verify changes with: ankiview -c '$COLLECTION' view $note_id --json" + else + fail "edit command failed (exit $exit_code)" + fi + + # Test edit on nonexistent note + output=$(run_ankiview edit 99999999999 2>&1) && { + fail "edit nonexistent note should fail" + } || { + pass "edit nonexistent note → error" + } + + rm -f "$md_file" + sep +} + +# ── Main ───────────────────────────────────────────────────────────────────── + +main() { + local filter="${1:-all}" + + echo "" + echo "╔══════════════════════════════════════════════════════════════╗" + echo "║ ankiview manual test — isolated environment ║" + echo "║ Collection: ~/xxx/ankiview-test/ ║" + echo "╚══════════════════════════════════════════════════════════════╝" + echo "" + + preflight + + case "$filter" in + all) + test_collect + test_tag + test_edit + ;; + collect) test_collect ;; + tag) test_tag ;; + edit) test_edit ;; + *) die "Unknown filter: $filter (use: all, collect, tag, edit)" ;; + esac + + # Summary + echo "" + echo "╔══════════════════════════════════════╗" + printf "║ ${GREEN}PASSED: %-4d${NC} ${RED}FAILED: %-4d${NC} ${YELLOW}SKIP: %-3d${NC} ║\n" "$passed" "$failed" "$skipped" + echo "╚══════════════════════════════════════╝" + echo "" + + if [[ $failed -gt 0 ]]; then + exit 1 + fi +} + +main "$@"