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
2 changes: 1 addition & 1 deletion .envrc
89 changes: 31 additions & 58 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:: ## ##################################################################
Expand Down Expand Up @@ -58,8 +47,12 @@ 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: ## 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
Expand All @@ -75,45 +68,54 @@ 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
pushd $(pkg_src) && cargo doc --open

.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
Expand Down Expand Up @@ -159,7 +161,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
Expand All @@ -169,13 +170,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


################################################################################
Expand All @@ -186,21 +188,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
Expand All @@ -227,17 +214,3 @@ help:

debug: ## debug
@echo "-D- CODE_DIR: $(CODE_DIR)"


.PHONY: list
list: * ## list
@echo $^

.PHONY: list2
%: %.md ## list2
@echo $^


%-plan: ## call with: make <whatever>-plan
@echo $@ : $*
@echo $@ : $^
79 changes: 71 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <NOTE_ID> <tag>` 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.
Expand Down
6 changes: 6 additions & 0 deletions ankiview/src/application/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
86 changes: 86 additions & 0 deletions ankiview/src/application/note_editor.rs
Original file line number Diff line number Diff line change
@@ -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<R: NoteRepository> {
repository: R,
}

impl<R: NoteRepository> NoteEditor<R> {
pub fn new(repository: R) -> Self {
Self { repository }
}

pub fn edit(&mut self, note_id: i64) -> Result<bool> {
// 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(&note);
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, &note)?;
edited_template.validate(&note)?;

// 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)
}
}
Loading
Loading