diff --git a/.bumpversion.toml b/.bumpversion.toml index 17d51c4..d6cd2df 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "0.4.0" +current_version = "1.0.0" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" serialize = ["{major}.{minor}.{patch}"] search = "{current_version}" diff --git a/.idea/anki.iml b/.idea/anki.iml index 1cfe77c..ad14982 100644 --- a/.idea/anki.iml +++ b/.idea/anki.iml @@ -4,6 +4,7 @@ + diff --git a/Makefile b/Makefile index e170303..69994b0 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,28 @@ ADMIN:: ## ################################################################## .PHONY: init-env init-env: ## init-env @rm -fr ~/xxx/* - @mkdir -p ~/xxx + @mkdir -p "$(HOME)/xxx/ankiview-test/" + cp -r ankiview/tests/fixtures/test_collection/* "$(HOME)/xxx/ankiview-test/" + cp -v ankiview/examples/image-test.md.ori ankiview/examples/image-test.md + cp -v ./ankiview/tests/fixtures/munggoggo.png ./ankiview/examples/munggoggo.png + +.PHONY: create-note +create-note: init-env ## create a note from markdown + # cargo run --bin ankiview -- -c "$(HOME)/xxx/ankiview-test/User 1/collection.anki2" view 1695797540371 + ~/dev/s/private/ankiview/ankiview/target/debug/ankiview -c "$(HOME)/xxx/ankiview-test/User 1/collection.anki2" collect ./ankiview/examples/image-test.md + ~/dev/s/private/ankiview/ankiview/target/debug/ankiview -c "$(HOME)/xxx/ankiview-test/User 1/collection.anki2" list | grep 'This is an image test!' + + @echo + @echo "---- Following test should fail ---" + @echo + cp ./ankiview/tests/fixtures/gh_activity.png ./ankiview/examples/munggoggo.png + ~/dev/s/private/ankiview/ankiview/target/debug/ankiview -c "$(HOME)/xxx/ankiview-test/User 1/collection.anki2" collect ./ankiview/examples/image-test.md + +.PHONY: anki +anki: ## anki + -pkill anki + # specify base folder with -b + open /Applications/Anki.app --args -b $(HOME)/xxx/ankiview-test .PHONY: test test: ## Run all tests (unit, integration, and doc tests) with debug logging diff --git a/README.md b/README.md index bba6fb4..90a7339 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,12 @@ AnkiView is a command-line tool that lets you quickly view Anki notes directly f ## Features ✨ -- View any note by its ID in your default browser -- Delete notes from your collection via CLI +- **View notes** - View any note by its ID in your default browser +- **Delete notes** - Delete notes from your collection via CLI +- **Import markdown** - Convert markdown flashcards to Anki notes +- **Smart updates** - Automatically track cards with ID comments +- **Media handling** - Import images from markdown files +- **Hash caching** - Skip unchanged files for fast re-imports - Automatic collection file detection - Support for multiple Anki profiles - LaTeX math rendering support @@ -69,6 +73,105 @@ ankiview -c /path/to/collection.anki2 delete 1234567890 ankiview -p "User 1" delete 1234567890 ``` +### Collect markdown cards + +Import markdown flashcards into your Anki collection: + +```bash +# Import a single file +ankiview collect notes.md + +# Import a directory (non-recursive) +ankiview collect notes/ + +# Import recursively (all subdirectories) +ankiview collect -r notes/ +``` + +**Markdown Format** + +Basic cards (question and answer): +```markdown +--- +Deck: Programming +Tags: rust basics + +1. What is Rust? +> A systems programming language + +2. What is Cargo? +> Rust's package manager +--- +``` + +Cloze deletion cards: +```markdown +--- +Deck: Programming + +1. Rust provides {memory safety} without garbage collection. +2. The {{c1::borrow checker}} ensures {{c2::safe concurrency}}. +--- +``` + +Cards with images: +```markdown +--- +Deck: ComputerScience + +1. What type of graph is this? +> ![Graph diagram](images/dag.png) +> A directed acyclic graph (DAG) +--- +``` + +**How It Works** + +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/ + +After the first run, your markdown will have ID comments: +```markdown + +1. What is Rust? +> A systems programming language +``` + +This allows you to edit the content and re-run collect to update (not duplicate) the cards. + +**Advanced Usage** + +```bash +# Recover lost IDs by searching Anki +ankiview collect -u notes/ + +# Force rebuild (bypass cache) +ankiview collect -f notes/ + +# Overwrite existing media files +ankiview collect --force notes/ + +# Continue on errors, report at end +ankiview collect -i notes/ + +# Combine flags for batch processing +ankiview collect -ri notes/ +``` + +**Flag Reference** + +| Flag | Description | +|------|-------------| +| `-r, --recursive` | Process subdirectories | +| `--force` | Overwrite conflicting media files | +| `-i, --ignore-errors` | Continue processing on errors | +| `-f, --full-sync` | Bypass hash cache (force rebuild) | +| `-u, --update-ids` | Search Anki for existing notes by content | + +**Performance Note:** AnkiView maintains a hash cache to skip unchanged files. Use `-f` to force processing all files. + ### Debug logging Enable debug logging for any command (global flags can appear before or after subcommand): @@ -129,9 +232,24 @@ RUST_LOG=debug cargo test - Verify the collection path 2. **"Failed to open Anki collection"** - - Make sure Anki isn't running + - Make sure Anki isn't running (required for all commands) - Check file permissions +3. **"Different file with the same name already exists"** (collect command) + - Media file conflict detected + - Use `--force` flag to overwrite existing media files + - Or rename your image file to avoid conflict + +4. **Duplicate cards created** (collect command) + - Ensure ID comments (``) are preserved in markdown + - Use `--update-ids` flag to recover lost IDs + - Check that you didn't manually modify or remove ID comments + +5. **Cards not updating** (collect command) + - File may be unchanged (check hash cache) + - Use `-f` flag to force rebuild + - Verify ID comments are correct and match Anki notes + ## Contributing 🤝 Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/VERSION b/VERSION index 1d0ba9e..3eefcb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +1.0.0 diff --git a/ankiview/Cargo.lock b/ankiview/Cargo.lock index 4be8b99..9fe469a 100644 --- a/ankiview/Cargo.lock +++ b/ankiview/Cargo.lock @@ -92,7 +92,7 @@ dependencies = [ "bytes", "chrono", "coarsetime", - "convert_case", + "convert_case 0.6.0", "csv", "data-encoding", "difflib", @@ -106,7 +106,7 @@ dependencies = [ "futures", "hex", "htmlescape", - "hyper", + "hyper 1.6.0", "id_tree", "inflections", "itertools 0.13.0", @@ -124,9 +124,9 @@ dependencies = [ "pulldown-cmark", "rand", "regex", - "reqwest", + "reqwest 0.12.12", "rusqlite", - "rustls-pemfile", + "rustls-pemfile 2.2.0", "scopeguard", "serde", "serde-aux", @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "ankiview" -version = "0.4.0" +version = "1.0.0" dependencies = [ "anki", "anyhow", @@ -228,14 +228,20 @@ dependencies = [ "dirs 6.0.0", "html-escape", "itertools 0.14.0", + "lazy_static", + "markdown-it", "regex", + "reqwest 0.11.27", "rstest 0.24.0", "serde", "serde_json", + "sha2", "tempfile", - "thiserror 2.0.11", + "thiserror 2.0.17", + "toml", "tracing", "tracing-subscriber", + "walkdir", ] [[package]] @@ -303,6 +309,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "argparse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f8ebf5827e4ac4fd5946560e6a99776ea73b596d80898f357007317a7141e47" + [[package]] name = "arrayref" version = "0.3.9" @@ -395,10 +407,10 @@ dependencies = [ "axum-macros", "bytes", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.6.0", "hyper-util", "itoa", "matchit", @@ -412,7 +424,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower", "tower-layer", @@ -440,13 +452,13 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", "rustversion", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", @@ -464,8 +476,8 @@ dependencies = [ "fastrand", "futures-util", "headers", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", "mime", "multer", @@ -520,6 +532,15 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bincode" version = "2.0.0-rc.3" @@ -646,7 +667,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f8ebbf7d5c8bdc269260bd8e7ce08e488e6625da19b3d80ca34a729d78a77ab" dependencies = [ "ahash", - "bincode", + "bincode 2.0.0-rc.3", "burn-autodiff", "burn-candle", "burn-common", @@ -706,7 +727,7 @@ dependencies = [ "strum", "strum_macros", "tempfile", - "thiserror 2.0.11", + "thiserror 2.0.17", ] [[package]] @@ -912,10 +933,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.15" +version = "1.2.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c736e259eea577f443d5c86c304f9f4ae0295c43f3ba05c21f1d66b5f06001af" +checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -1037,12 +1059,38 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "constant_time_eq" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "convert_case" version = "0.6.0" @@ -1228,7 +1276,7 @@ dependencies = [ "cubecl-macros", "cubecl-runtime", "derive-new 0.6.0", - "derive_more", + "derive_more 1.0.0", "half", "log", "num-traits", @@ -1437,6 +1485,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive-new" version = "0.6.0" @@ -1470,6 +1529,19 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.98", +] + [[package]] name = "derive_more" version = "1.0.0" @@ -1570,6 +1642,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dyn-stack" version = "0.10.0" @@ -1610,6 +1688,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "entities" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -1680,12 +1764,29 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -1758,6 +1859,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1765,7 +1875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1779,6 +1889,12 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -2317,6 +2433,25 @@ dependencies = [ "bitflags 2.8.0", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.4.1" @@ -2381,7 +2516,7 @@ dependencies = [ "base64 0.21.7", "bytes", "headers-core", - "http", + "http 1.2.0", "httpdate", "mime", "sha1", @@ -2393,7 +2528,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http", + "http 1.2.0", ] [[package]] @@ -2458,6 +2593,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.2.0" @@ -2469,6 +2615,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -2476,7 +2633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.2.0", ] [[package]] @@ -2487,8 +2644,8 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -2504,6 +2661,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.6.0" @@ -2513,8 +2694,8 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -2524,6 +2705,19 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "hyper-util" version = "0.1.10" @@ -2533,9 +2727,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.2.0", + "http-body 1.0.1", + "hyper 1.6.0", "pin-project-lite", "socket2", "tokio", @@ -2699,6 +2893,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "1.0.3" @@ -2891,6 +3095,21 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linkify" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780" +dependencies = [ + "memchr", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2946,6 +3165,29 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" +[[package]] +name = "markdown-it" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f99c010929c8217b2dc0940954267a2e15a15f17cb309cd1f299e21933f84fac" +dependencies = [ + "argparse", + "const_format", + "derivative", + "derive_more 0.99.20", + "downcast-rs", + "entities", + "html-escape", + "linkify", + "mdurl", + "once_cell", + "readonly", + "regex", + "stacker", + "syntect", + "unicode-general-category", +] + [[package]] name = "markup5ever" version = "0.12.1" @@ -3000,6 +3242,17 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" +[[package]] +name = "mdurl" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5736ba45bbac8f7ccc99a897f88ce85e508a18baec973a040f2514e6cdbff0d2" +dependencies = [ + "idna 0.3.0", + "once_cell", + "regex", +] + [[package]] name = "memchr" version = "2.7.4" @@ -3025,7 +3278,7 @@ dependencies = [ "bitflags 2.8.0", "block", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "log", "objc", "paste", @@ -3082,7 +3335,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.2.0", "httparse", "memchr", "mime", @@ -3117,6 +3370,23 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndarray" version = "0.15.6" @@ -3376,6 +3646,50 @@ version = "1.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" +[[package]] +name = "openssl" +version = "0.10.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +dependencies = [ + "bitflags 2.8.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3566,6 +3880,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "portable-atomic" version = "1.10.0" @@ -3722,6 +4049,15 @@ dependencies = [ "prost", ] +[[package]] +name = "psm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e66fcd288453b748497d8fb18bccc83a16b0518e3906d4b8df0a8d42d93dbb1c" +dependencies = [ + "cc", +] + [[package]] name = "pulldown-cmark" version = "0.9.6" @@ -3760,6 +4096,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.38" @@ -3865,6 +4210,17 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "readonly" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2a62d85ed81ca5305dc544bd42c8804c5060b78ffa5ad3c64b0fb6a8c13d062" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "reborrow" version = "0.5.5" @@ -3899,7 +4255,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.15", "libredox", - "thiserror 2.0.11", + "thiserror 2.0.17", ] [[package]] @@ -3958,6 +4314,46 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "reqwest" version = "0.12.12" @@ -3968,10 +4364,10 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.6.0", "hyper-util", "ipnet", "js-sys", @@ -3984,7 +4380,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-socks", "tokio-util", @@ -4128,6 +4524,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -4193,12 +4598,44 @@ dependencies = [ "regex", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.8.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "0.10.3" @@ -4310,6 +4747,15 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_tuple" version = "0.5.0" @@ -4481,6 +4927,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "stacker" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -4568,6 +5027,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4588,6 +5053,27 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode 1.3.3", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "plist", + "regex-syntax 0.8.5", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.17", + "walkdir", + "yaml-rust", +] + [[package]] name = "sysctl" version = "0.5.5" @@ -4630,6 +5116,27 @@ dependencies = [ "windows 0.57.0", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "systemstat" version = "0.2.4" @@ -4700,11 +5207,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.11" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.11", + "thiserror-impl 2.0.17", ] [[package]] @@ -4720,9 +5227,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.11" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -4832,6 +5339,16 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-socks" version = "0.5.2" @@ -4857,11 +5374,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + [[package]] name = "toml_datetime" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] [[package]] name = "toml_edit" @@ -4870,6 +5402,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" dependencies = [ "indexmap", + "serde", + "serde_spanned", "toml_datetime", "winnow", ] @@ -4883,7 +5417,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -4898,8 +5432,8 @@ checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ "bitflags 2.8.0", "bytes", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", "pin-project-lite", "tower-layer", @@ -5129,6 +5663,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-general-category" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2281c8c1d221438e373249e065ca4989c4c36952c211ff21a0ee91c44a3869e7" + [[package]] name = "unicode-ident" version = "1.0.17" @@ -5169,7 +5715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", - "idna", + "idna 1.0.3", "percent-encoding", ] @@ -5653,6 +6199,12 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-registry" version = "0.2.0" @@ -5719,6 +6271,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -5849,6 +6410,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen-rt" version = "0.33.0" @@ -5888,6 +6459,15 @@ version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5b940ebc25896e71dd073bad2dbaa2abfe97b0a391415e22ad1326d9c54e3c4" +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "yoke" version = "0.7.5" diff --git a/ankiview/Cargo.toml b/ankiview/Cargo.toml index f0858d4..d66bcaf 100644 --- a/ankiview/Cargo.toml +++ b/ankiview/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ankiview" -version = "0.4.0" +version = "1.0.0" edition = "2021" authors = ["sysid "] description = "Fast Anki card viewer" @@ -28,6 +28,14 @@ thiserror = "2.0.11" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +# inka dependencies +markdown-it = "0.6" +toml = "0.8" +sha2 = "0.10" +walkdir = "2.4" +reqwest = { version = "0.11", features = ["blocking"] } +lazy_static = "1.4" + [[bin]] name = "build_test_collection" path = "tests/fixtures/build_test_collection.rs" diff --git a/ankiview/examples/image-test.md b/ankiview/examples/image-test.md new file mode 100644 index 0000000..ecfb3d4 --- /dev/null +++ b/ankiview/examples/image-test.md @@ -0,0 +1,16 @@ +# image test +1. Without --force: Create a card with an image, modify the image file content, then run collect + again - should error with "Different file with the same name ... Use --force to overwrite." +2. With --force: Same scenario above but with --force flag - should successfully overwrite the + media file +3. Identical media file: Run collect twice with same image file - should skip copy operation in + both modes (no error) + +--- + +1. This is an image test! +> ![mungoggo](munggoggo.png) +> answer + +--- + diff --git a/ankiview/examples/image-test.md.ori b/ankiview/examples/image-test.md.ori new file mode 100644 index 0000000..714ec59 --- /dev/null +++ b/ankiview/examples/image-test.md.ori @@ -0,0 +1,15 @@ +# image test +1. Without --force: Create a card with an image, modify the image file content, then run collect + again - should error with "Different file with the same name ... Use --force to overwrite." +2. With --force: Same scenario above but with --force flag - should successfully overwrite the + media file +3. Identical media file: Run collect twice with same image file - should skip copy operation in + both modes (no error) + +--- +1. This is an image test! +> ![mungoggo](munggoggo.png) +> answer + +--- + diff --git a/ankiview/examples/munggoggo.png b/ankiview/examples/munggoggo.png new file mode 100644 index 0000000..f24ea2b Binary files /dev/null and b/ankiview/examples/munggoggo.png differ diff --git a/ankiview/examples/sample-notes.md b/ankiview/examples/sample-notes.md new file mode 100644 index 0000000..f27ad1b --- /dev/null +++ b/ankiview/examples/sample-notes.md @@ -0,0 +1,91 @@ +# Sample Anki Notes in Markdown + +This is an example markdown file showing how to write Anki flashcards. + +--- +Deck: Programming +Tags: rust basics + +1. What is Rust? +> Rust is a systems programming language focused on safety, speed, and concurrency. + +2. What is Cargo? +> Cargo is Rust's package manager and build system. + +3. Rust's ownership system prevents {data races} at compile time. + +4. What are the three rules of ownership in Rust? +> 1. Each value has a variable called its owner +> 2. There can only be one owner at a time +> 3. When the owner goes out of scope, the value is dropped +--- + +--- +Deck: Mathematics +Tags: algebra formulas + +1. What is the quadratic formula? +> $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$ + +2. The {Pythagorean theorem} states that $a^2 + b^2 = c^2$. + +3. What is the formula for the area of a circle? +> $A = \pi r^2$ where $r$ is the radius +--- + +## Usage + +Process this file with: + +```bash +# Process single file +ankiview collect examples/sample-notes.md + +# Process with specific collection +ankiview -c /path/to/collection.anki2 collect examples/sample-notes.md + +# Process entire directory recursively +ankiview collect ./my-notes --recursive +``` + +## Format Guide + +### Sections +- Sections are delimited by `---` +- Each section can have `Deck:` and `Tags:` metadata +- All cards in a section share the same deck and tags + +### Card Types + +**Basic Cards** (front/back): +```markdown +1. Question here? +> Answer here +``` + +**Cloze Deletions** (fill-in-the-blank): +```markdown +1. This is a {cloze deletion} example. +``` + +### Math Support + +Use `$` for inline math: `$E = mc^2$` + +Use `$$` for block math: +```markdown +$$ +\int_a^b f(x) dx +$$ +``` + +### IDs + +After first run, Anki IDs are injected: +```markdown + +1. Question? +> Answer +``` + +Don't modify IDs - they link to Anki notes for updates. diff --git a/ankiview/src/cli/args.rs b/ankiview/src/cli/args.rs index 30521d9..496c482 100644 --- a/ankiview/src/cli/args.rs +++ b/ankiview/src/cli/args.rs @@ -49,4 +49,44 @@ pub enum Command { #[arg(value_name = "SEARCH")] search: Option, }, + + /// Collect markdown cards into Anki + /// + /// Processes markdown files containing flashcards and imports them into your Anki collection. + /// Cards are automatically tracked with ID comments, allowing updates without creating duplicates. + Collect { + /// Path to markdown file or directory containing .md files + #[arg(value_name = "PATH")] + path: PathBuf, + + /// Process directory recursively, scanning all subdirectories for .md files. + /// Without this flag, only processes files in the specified directory (non-recursive). + #[arg(short, long)] + recursive: bool, + + /// Overwrite media files when filename conflicts occur in collection.media/. + /// Without this flag, processing stops with an error if a different file with the same name exists. + /// Use when you want to replace existing images with updated versions. + #[arg(long)] + force: bool, + + /// Continue processing remaining files even if errors occur. + /// Errors are collected and reported at the end instead of stopping immediately. + /// Useful for batch processing where you want to see all issues at once. + #[arg(short, long)] + ignore_errors: bool, + + /// Process all files regardless of hash cache, forcing a complete rebuild. + /// By default, unchanged files are skipped for performance (tracked via SHA256 hashes). + /// Use this when you want to ensure all cards are re-processed from scratch. + #[arg(short = 'f', long)] + full_sync: bool, + + /// Search Anki for existing notes by content and inject their IDs into markdown. + /// Prevents duplicate creation when markdown files lack ID comments (). + /// Useful for recovering lost IDs or importing cards from other sources. + /// Matches notes by comparing HTML field content. + #[arg(short = 'u', long)] + update_ids: bool, + }, } diff --git a/ankiview/src/infrastructure/anki.rs b/ankiview/src/infrastructure/anki.rs index 0ddcae6..29fd47b 100644 --- a/ankiview/src/infrastructure/anki.rs +++ b/ankiview/src/infrastructure/anki.rs @@ -65,6 +65,241 @@ impl AnkiRepository { pub fn media_dir(&self) -> &Path { &self.media_dir } + + /// Find or create a Basic note type with front/back fields + /// Returns the notetype ID + pub fn find_or_create_basic_notetype(&mut self) -> Result { + use anki::notetype::NotetypeKind; + + // Look for existing Basic notetype + let all_notetypes = self + .collection + .get_all_notetypes() + .context("Failed to get all notetypes")?; + + // Find a Basic-type notetype (non-cloze) + for notetype in all_notetypes { + if notetype.config.kind() != NotetypeKind::Cloze && notetype.fields.len() >= 2 { + // Found a suitable basic notetype + debug!(notetype_id = notetype.id.0, name = %notetype.name, "Found existing Basic notetype"); + return Ok(notetype.id.0); + } + } + + // No suitable notetype found - this shouldn't happen in normal Anki collections + // For now, return an error. In the future, we could create one programmatically. + Err(anyhow::anyhow!( + "No Basic notetype found. Please create a Basic notetype in Anki first." + )) + } + + /// Find or create a Cloze note type + /// Returns the notetype ID + pub fn find_or_create_cloze_notetype(&mut self) -> Result { + use anki::notetype::NotetypeKind; + + // Look for existing Cloze notetype + let all_notetypes = self + .collection + .get_all_notetypes() + .context("Failed to get all notetypes")?; + + // Find a Cloze-type notetype + for notetype in all_notetypes { + if notetype.config.kind() == NotetypeKind::Cloze { + // Found a cloze notetype + debug!(notetype_id = notetype.id.0, name = %notetype.name, "Found existing Cloze notetype"); + return Ok(notetype.id.0); + } + } + + // No cloze notetype found - this shouldn't happen in normal Anki collections + Err(anyhow::anyhow!( + "No Cloze notetype found. Please create a Cloze notetype in Anki first." + )) + } + + /// Create a new Basic note in the collection + /// Returns the created note ID + pub fn create_basic_note( + &mut self, + front: &str, + back: &str, + deck_name: &str, + tags: &[String], + ) -> Result { + use anki::notes::Note; + use anki::notetype::NotetypeId; + + // Find or create the Basic notetype + let notetype_id = self.find_or_create_basic_notetype()?; + + // Get the notetype to create the note + let notetype = self + .collection + .get_notetype(NotetypeId(notetype_id)) + .context("Failed to get notetype")? + .context("Notetype not found")?; + + // Find or create the deck + let deck_id = self + .collection + .get_or_create_normal_deck(deck_name) + .context("Failed to get or create deck")? + .id; + + // Create a new note + let mut note = Note::new(¬etype); + note.set_field(0, front) + .context("Failed to set front field")?; + note.set_field(1, back) + .context("Failed to set back field")?; + + // Add tags + for tag in tags { + note.tags.push(tag.clone()); + } + + // Add the note to the collection + self.collection + .add_note(&mut note, deck_id) + .context("Failed to add note to collection")?; + + debug!(note_id = note.id.0, "Created Basic note"); + Ok(note.id.0) + } + + /// Create a new Cloze note in the collection + /// Returns the created note ID + pub fn create_cloze_note( + &mut self, + text: &str, + deck_name: &str, + tags: &[String], + ) -> Result { + use anki::notes::Note; + use anki::notetype::NotetypeId; + + // Find or create the Cloze notetype + let notetype_id = self.find_or_create_cloze_notetype()?; + + // Get the notetype to create the note + let notetype = self + .collection + .get_notetype(NotetypeId(notetype_id)) + .context("Failed to get notetype")? + .context("Notetype not found")?; + + // Find or create the deck + let deck_id = self + .collection + .get_or_create_normal_deck(deck_name) + .context("Failed to get or create deck")? + .id; + + // Create a new note + let mut note = Note::new(¬etype); + note.set_field(0, text) + .context("Failed to set text field")?; + + // Add tags + for tag in tags { + note.tags.push(tag.clone()); + } + + // Add the note to the collection + self.collection + .add_note(&mut note, deck_id) + .context("Failed to add note to collection")?; + + debug!(note_id = note.id.0, "Created Cloze note"); + Ok(note.id.0) + } + + /// Update an existing note's fields + /// For Basic notes: updates front (field 0) and back (field 1) + /// For Cloze notes: updates text (field 0) + pub fn update_note(&mut self, note_id: i64, fields: &[String]) -> Result<()> { + use anki::notes::NoteId; + + // Get the existing note + 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))?; + + // Update each field + 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))?; + } + + // Save the updated note + self.collection + .update_note(&mut note) + .context("Failed to update note in collection")?; + + debug!(note_id, "Updated note fields"); + Ok(()) + } + + /// Check if a note exists by ID + pub fn note_exists(&self, note_id: i64) -> Result { + use anki::notes::NoteId; + + let exists = self + .collection + .storage + .get_note(NoteId(note_id)) + .context("Failed to check note existence")? + .is_some(); + + Ok(exists) + } + + /// Search for notes by HTML content (for --update-ids) + /// Returns a vector of note IDs that match the given HTML fields + pub fn search_by_html(&mut self, fields: &[String]) -> Result> { + use anki::search::SearchNode; + + // Get all notes in the collection + let search_node = SearchNode::WholeCollection; + let note_ids = self + .collection + .search_notes_unordered(search_node) + .context("Failed to search notes")?; + + let mut matching_ids = Vec::new(); + + // Check each note to see if its fields match + for note_id in note_ids { + if let Ok(Some(note)) = self.collection.storage.get_note(note_id) { + let note_fields: Vec = + note.fields().iter().map(|f| f.to_string()).collect(); + + // For basic cards, match front and back (first 2 fields) + // For cloze cards, match the text field (first field) + let matches = if fields.len() == 2 && note_fields.len() >= 2 { + // Basic card: match both fields + note_fields[0] == fields[0] && note_fields[1] == fields[1] + } else if fields.len() == 1 && !note_fields.is_empty() { + // Cloze card: match first field + note_fields[0] == fields[0] + } else { + false + }; + + if matches { + debug!(note_id = note_id.0, "Found matching note"); + matching_ids.push(note_id.0); + } + } + } + + Ok(matching_ids) + } } impl NoteRepository for AnkiRepository { @@ -180,3 +415,167 @@ impl NoteRepository for AnkiRepository { Ok(notes) } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + // Helper to create a temporary test collection + fn create_test_collection() -> Result<(TempDir, AnkiRepository)> { + let temp_dir = TempDir::new()?; + let collection_path = temp_dir.path().join("collection.anki2"); + + // Create a new Anki collection + let collection = CollectionBuilder::new(&collection_path).build()?; + drop(collection); // Close it + + // Open it with our repository + let repo = AnkiRepository::new(&collection_path)?; + + Ok((temp_dir, repo)) + } + + #[test] + fn given_new_collection_when_finding_basic_notetype_then_creates_and_returns_id() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + + let notetype_id = repo.find_or_create_basic_notetype().unwrap(); + + assert!(notetype_id > 0); + } + + #[test] + fn given_existing_basic_notetype_when_finding_then_returns_same_id() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + + let first_id = repo.find_or_create_basic_notetype().unwrap(); + let second_id = repo.find_or_create_basic_notetype().unwrap(); + + assert_eq!(first_id, second_id); + } + + #[test] + fn given_new_collection_when_finding_cloze_notetype_then_creates_and_returns_id() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + + let notetype_id = repo.find_or_create_cloze_notetype().unwrap(); + + assert!(notetype_id > 0); + } + + #[test] + fn given_existing_cloze_notetype_when_finding_then_returns_same_id() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + + let first_id = repo.find_or_create_cloze_notetype().unwrap(); + let second_id = repo.find_or_create_cloze_notetype().unwrap(); + + assert_eq!(first_id, second_id); + } + + #[test] + fn given_basic_card_fields_when_creating_note_then_returns_note_id() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + + let note_id = repo + .create_basic_note( + "What is Rust?", + "A systems programming language", + "Default", + &["rust".to_string(), "programming".to_string()], + ) + .unwrap(); + + assert!(note_id > 0); + } + + #[test] + fn given_basic_note_when_created_then_can_retrieve() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + + let note_id = repo + .create_basic_note("Front", "Back", "Default", &[]) + .unwrap(); + + // Should be able to retrieve the note + let note = repo.get_note(note_id).unwrap(); + assert_eq!(note.id, note_id); + assert!(note.front.contains("Front")); + assert!(note.back.contains("Back")); + } + + #[test] + fn given_cloze_text_when_creating_note_then_returns_note_id() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + + let note_id = repo + .create_cloze_note( + "The capital of {{c1::France}} is {{c2::Paris}}", + "Default", + &["geography".to_string()], + ) + .unwrap(); + + assert!(note_id > 0); + } + + #[test] + fn given_cloze_note_when_created_then_can_retrieve() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + + let cloze_text = "Answer: {{c1::42}}"; + let note_id = repo.create_cloze_note(cloze_text, "Default", &[]).unwrap(); + + // Should be able to retrieve the note + let note = repo.get_note(note_id).unwrap(); + assert_eq!(note.id, note_id); + assert!(note.front.contains("42")); + } + + #[test] + fn given_existing_note_when_updating_then_fields_change() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + + // Create a note + let note_id = repo + .create_basic_note("Original Front", "Original Back", "Default", &[]) + .unwrap(); + + // Update it + let new_fields = vec!["Updated Front".to_string(), "Updated Back".to_string()]; + repo.update_note(note_id, &new_fields).unwrap(); + + // Retrieve and verify + let note = repo.get_note(note_id).unwrap(); + assert!(note.front.contains("Updated Front")); + assert!(note.back.contains("Updated Back")); + } + + #[test] + fn given_nonexistent_note_when_updating_then_returns_error() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + + let result = repo.update_note(9999999, &["Test".to_string()]); + + assert!(result.is_err()); + } + + #[test] + fn given_existing_note_when_checking_exists_then_returns_true() { + let (_temp_dir, mut repo) = create_test_collection().unwrap(); + + let note_id = repo + .create_basic_note("Front", "Back", "Default", &[]) + .unwrap(); + + assert!(repo.note_exists(note_id).unwrap()); + } + + #[test] + fn given_nonexistent_note_when_checking_exists_then_returns_false() { + let (_temp_dir, repo) = create_test_collection().unwrap(); + + assert!(!repo.note_exists(9999999).unwrap()); + } +} diff --git a/ankiview/src/inka/application/card_collector.rs b/ankiview/src/inka/application/card_collector.rs new file mode 100644 index 0000000..89c0db6 --- /dev/null +++ b/ankiview/src/inka/application/card_collector.rs @@ -0,0 +1,618 @@ +use crate::infrastructure::anki::AnkiRepository; +use crate::inka::infrastructure::file_writer; +use crate::inka::infrastructure::hasher::HashCache; +use crate::inka::infrastructure::markdown::card_parser; +use crate::inka::infrastructure::markdown::converter; +use crate::inka::infrastructure::markdown::section_parser; +use crate::inka::infrastructure::media_handler; +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use tracing::debug; + +/// Main use case for collecting markdown cards into Anki +pub struct CardCollector { + _collection_path: PathBuf, + media_dir: PathBuf, + repository: AnkiRepository, + force: bool, + hash_cache: Option, + update_ids: bool, + ignore_errors: bool, + errors: Vec, +} + +impl CardCollector { + /// Create a new CardCollector with Anki collection path + pub fn new( + collection_path: impl AsRef, + force: bool, + full_sync: bool, + update_ids: bool, + ignore_errors: bool, + ) -> Result { + let collection_path = collection_path.as_ref().to_path_buf(); + + // Determine media directory path + let media_dir = collection_path + .parent() + .ok_or_else(|| anyhow::anyhow!("Invalid collection path"))? + .join("collection.media"); + + // Create media directory if it doesn't exist + if !media_dir.exists() { + std::fs::create_dir_all(&media_dir).context("Failed to create media directory")?; + } + + // Determine hash cache path (in same directory as collection) + let cache_path = collection_path + .parent() + .expect("Invalid collection path") + .join("ankiview_hashes.json"); + + // Load hash cache unless full_sync is enabled + let hash_cache = if full_sync { + None + } else { + Some(HashCache::load(&cache_path).context("Failed to load hash cache")?) + }; + + // Open repository + let repository = AnkiRepository::new(&collection_path)?; + + Ok(Self { + _collection_path: collection_path, + media_dir, + repository, + force, + hash_cache, + update_ids, + ignore_errors, + errors: Vec::new(), + }) + } + + /// Get accumulated errors from processing + pub fn errors(&self) -> &[String] { + &self.errors + } + + /// Process a single markdown file and add/update cards in Anki + /// Returns the number of cards processed + pub fn process_file(&mut self, markdown_path: impl AsRef) -> Result { + let markdown_path = markdown_path.as_ref(); + + // Handle error according to ignore_errors flag + match self.process_file_impl(markdown_path) { + Ok(count) => Ok(count), + Err(e) => { + if self.ignore_errors { + // Collect error and continue + let error_msg = format!("{}: {:#}", markdown_path.display(), e); + self.errors.push(error_msg); + Ok(0) + } else { + Err(e) + } + } + } + } + + /// Internal implementation of process_file + fn process_file_impl(&mut self, markdown_path: &Path) -> Result { + // Check if file has changed (skip if unchanged and cache exists) + if let Some(cache) = &self.hash_cache { + let has_changed = cache + .file_has_changed(markdown_path) + .context("Failed to check file hash")?; + + if !has_changed { + // File unchanged, skip processing + debug!(?markdown_path, "Skipping unchanged file"); + return Ok(0); + } + } + + // Read markdown file + let mut content = file_writer::read_markdown_file(markdown_path)?; + + // Extract and handle media files + let image_paths = media_handler::extract_image_paths(&content); + let mut path_mapping = HashMap::new(); + + for image_path in image_paths { + // Resolve relative paths relative to markdown file location + let markdown_dir = markdown_path + .parent() + .ok_or_else(|| anyhow::anyhow!("Cannot determine markdown file directory"))?; + let absolute_image_path = markdown_dir.join(&image_path); + + // Copy image to media directory + match media_handler::copy_media_to_anki( + &absolute_image_path, + &self.media_dir, + self.force, + ) { + Ok(filename) => { + debug!("Copied media file: {} -> {}", image_path, filename); + path_mapping.insert(image_path.clone(), filename); + } + Err(e) => { + return Err(e) + .with_context(|| format!("Failed to copy media file '{}'", image_path)); + } + } + } + + // Parse sections + let parser = section_parser::SectionParser::new(); + let sections = parser.parse(&content); + + if sections.is_empty() { + return Ok(0); + } + + // Convert sections to owned Strings to avoid borrowing issues when mutating content + let sections: Vec = sections.iter().map(|s| s.to_string()).collect(); + + let mut card_count = 0; + + for section in §ions { + // Extract metadata + let deck_name = + section_parser::extract_deck_name(section).unwrap_or_else(|| "Default".to_string()); + let tags = section_parser::extract_tags(section); + + // Extract note strings + let note_strings = section_parser::extract_note_strings(section); + + for note_str in note_strings { + // Extract existing ID if present + let existing_id = card_parser::extract_anki_id(¬e_str); + + // Determine card type and process + if card_parser::is_basic_card(¬e_str) { + // Parse basic card fields + let (front_md, back_md) = card_parser::parse_basic_card_fields(¬e_str)?; + + // Convert to HTML + let mut front_html = converter::markdown_to_html(&front_md); + let mut back_html = converter::markdown_to_html(&back_md); + + // Update media paths in HTML + front_html = + media_handler::update_media_paths_in_html(&front_html, &path_mapping); + back_html = + media_handler::update_media_paths_in_html(&back_html, &path_mapping); + + // Create or update note + if let Some(id) = existing_id { + // Update existing note + self.repository + .update_note(id, &[front_html.clone(), back_html.clone()])?; + } else if self.update_ids { + // --update-ids mode: search for existing note by HTML content + let matching_ids = self + .repository + .search_by_html(&[front_html.clone(), back_html.clone()])?; + + if let Some(&id) = matching_ids.first() { + // Found existing note, inject ID + debug!(note_id = id, "Found existing note for card, injecting ID"); + content = file_writer::inject_anki_id(&content, ¬e_str, id); + // Update the existing note with current content + self.repository.update_note(id, &[front_html, back_html])?; + } else { + // No match found, create new note + let id = self.repository.create_basic_note( + &front_html, + &back_html, + &deck_name, + &tags, + )?; + content = file_writer::inject_anki_id(&content, ¬e_str, id); + } + } else { + // Normal mode: create new note + let id = self.repository.create_basic_note( + &front_html, + &back_html, + &deck_name, + &tags, + )?; + + // Inject ID back into markdown + content = file_writer::inject_anki_id(&content, ¬e_str, id); + }; + + card_count += 1; + } else if card_parser::is_cloze_card(¬e_str) { + // Parse cloze card + let text_md = card_parser::parse_cloze_card_field(¬e_str)?; + + // Transform cloze syntax + let text_transformed = crate::inka::infrastructure::markdown::cloze_converter::convert_cloze_syntax(&text_md); + + // Convert to HTML + let mut text_html = converter::markdown_to_html(&text_transformed); + + // Update media paths in HTML + text_html = + media_handler::update_media_paths_in_html(&text_html, &path_mapping); + + // Create or update note + if let Some(id) = existing_id { + // Update existing note + self.repository.update_note(id, &[text_html.clone()])?; + } else if self.update_ids { + // --update-ids mode: search for existing note by HTML content + let matching_ids = self.repository.search_by_html(&[text_html.clone()])?; + + if let Some(&id) = matching_ids.first() { + // Found existing note, inject ID + debug!( + note_id = id, + "Found existing note for cloze card, injecting ID" + ); + content = file_writer::inject_anki_id(&content, ¬e_str, id); + // Update the existing note with current content + self.repository.update_note(id, &[text_html])?; + } else { + // No match found, create new note + let id = self + .repository + .create_cloze_note(&text_html, &deck_name, &tags)?; + content = file_writer::inject_anki_id(&content, ¬e_str, id); + } + } else { + // Normal mode: create new note + let id = self + .repository + .create_cloze_note(&text_html, &deck_name, &tags)?; + + // Inject ID back into markdown + content = file_writer::inject_anki_id(&content, ¬e_str, id); + }; + + card_count += 1; + } + } + } + + // Write updated content back to file if IDs were injected + file_writer::write_markdown_file(markdown_path, &content)?; + + // After successful processing, update hash cache + if let Some(cache) = &mut self.hash_cache { + cache + .update_hash(markdown_path) + .context("Failed to update file hash")?; + } + + Ok(card_count) + } + + /// Process a directory recursively + /// Returns the number of cards processed + pub fn process_directory(&mut self, dir_path: impl AsRef) -> Result { + let dir_path = dir_path.as_ref(); + + if !dir_path.is_dir() { + return Err(anyhow::anyhow!("Path is not a directory: {:?}", dir_path)); + } + + let mut total_count = 0; + + // Walk directory recursively + for entry in walkdir::WalkDir::new(dir_path) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + + // Only process markdown files + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") { + total_count += self.process_file(path)?; + } + } + + Ok(total_count) + } +} + +impl Drop for CardCollector { + fn drop(&mut self) { + // Save hash cache if it exists + if let Some(cache) = &self.hash_cache { + if let Err(e) = cache.save() { + // Use eprintln since we can't return Result from Drop + eprintln!("Warning: Failed to save hash cache: {}", e); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + // Test helper that creates a temporary test collection + fn create_test_collection() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { + use std::path::PathBuf; + let temp_dir = tempfile::tempdir().unwrap(); + + let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/test_collection/User 1/collection.anki2"); + let collection_path = temp_dir.path().join("collection.anki2"); + + std::fs::copy(&fixture_path, &collection_path).unwrap(); + + let media_dir = temp_dir.path().join("collection.media"); + std::fs::create_dir_all(&media_dir).unwrap(); + + (temp_dir, collection_path, media_dir) + } + + #[test] + fn given_markdown_with_basic_card_when_processing_then_creates_note() { + let (temp_dir, collection_path, _media_dir) = create_test_collection(); + + let markdown_path = temp_dir.path().join("test.md"); + let markdown_content = r#"--- +Deck: TestDeck + +1. What is Rust? +> A systems programming language +---"#; + fs::write(&markdown_path, markdown_content).unwrap(); + + let mut collector = + CardCollector::new(&collection_path, false, false, false, false).unwrap(); + let count = collector.process_file(&markdown_path).unwrap(); + + assert_eq!(count, 1); + } + + #[test] + fn given_markdown_with_cloze_card_when_processing_then_creates_note() { + let (temp_dir, collection_path, _media_dir) = create_test_collection(); + + let markdown_path = temp_dir.path().join("cloze.md"); + let markdown_content = r#"--- +Deck: TestDeck + +1. Rust is a {systems programming} language. +---"#; + fs::write(&markdown_path, markdown_content).unwrap(); + + let mut collector = + CardCollector::new(&collection_path, false, false, false, false).unwrap(); + let count = collector.process_file(&markdown_path).unwrap(); + + assert_eq!(count, 1); + } + + #[test] + fn given_markdown_with_multiple_cards_when_processing_then_creates_all() { + let (temp_dir, collection_path, _media_dir) = create_test_collection(); + + let markdown_path = temp_dir.path().join("multi.md"); + let markdown_content = r#"--- +Deck: TestDeck + +1. What is Rust? +> A systems programming language + +2. What is Cargo? +> Rust's package manager + +3. Rust was created by {Mozilla}. +---"#; + fs::write(&markdown_path, markdown_content).unwrap(); + + let mut collector = + CardCollector::new(&collection_path, false, false, false, false).unwrap(); + let count = collector.process_file(&markdown_path).unwrap(); + + assert_eq!(count, 3); + } + + #[test] + fn given_markdown_with_id_when_processing_second_time_then_updates_note() { + let (temp_dir, collection_path, _media_dir) = create_test_collection(); + + let markdown_path = temp_dir.path().join("update.md"); + let markdown_content = r#"--- +Deck: TestDeck + +1. What is Rust? +> A systems programming language +---"#; + fs::write(&markdown_path, markdown_content).unwrap(); + + let mut collector = + CardCollector::new(&collection_path, false, false, false, false).unwrap(); + + // First run creates note + let count1 = collector.process_file(&markdown_path).unwrap(); + assert_eq!(count1, 1); + + // Markdown should now have ID + let updated_content = fs::read_to_string(&markdown_path).unwrap(); + assert!(updated_content.contains("\n", anki_id); + let mut result = String::with_capacity(content.len() + id_comment.len()); + result.push_str(&content[..note_pos]); + result.push_str(&id_comment); + result.push_str(&content[note_pos..]); + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn given_markdown_file_when_reading_then_returns_content() { + // Create temp file + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.md"); + + let content = "# Test\n\nSome content"; + fs::write(&file_path, content).unwrap(); + + // Read file + let result = read_markdown_file(&file_path).unwrap(); + + assert_eq!(result, content); + } + + #[test] + fn given_file_with_ids_when_reading_then_preserves_ids() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.md"); + + let content = r#"--- +Deck: Test + + +1. Question? +> Answer! +---"#; + fs::write(&file_path, content).unwrap(); + + let result = read_markdown_file(&file_path).unwrap(); + + assert!(result.contains("")); + assert_eq!(result, content); + } + + #[test] + fn given_nonexistent_file_when_reading_then_returns_error() { + let result = read_markdown_file("/nonexistent/path/file.md"); + + assert!(result.is_err()); + } + + #[test] + fn given_note_without_id_when_injecting_then_adds_id() { + let content = r#"--- +Deck: Test + +1. Question? +> Answer! +---"#; + + let result = inject_anki_id(content, "1. Question?", 1234567890); + + assert!(result.contains("")); + assert!(result.contains("\n1. Question?")); + } + + #[test] + fn given_note_with_existing_id_when_injecting_then_unchanged() { + let content = r#"--- +Deck: Test + + +1. Question? +> Answer! +---"#; + + let result = inject_anki_id(content, "1. Question?", 1234567890); + + // Should keep original ID + assert!(result.contains("")); + assert!(!result.contains("")); + assert_eq!(result, content); + } + + #[test] + fn given_multiple_notes_when_injecting_then_targets_correct_note() { + let content = r#"--- +Deck: Test + +1. First question? +> First answer + +2. Second question? +> Second answer +---"#; + + let result = inject_anki_id(content, "2. Second question?", 5555555555); + + assert!(result.contains("\n2. Second question?")); + // First note should remain untouched + assert!(result.contains("1. First question?\n> First answer")); + } + + #[test] + fn given_note_pattern_when_injecting_then_preserves_formatting() { + let content = "Some text\n\n1. Question\n> Answer\n\nMore text"; + + let result = inject_anki_id(content, "1. Question", 1111111111); + + assert_eq!( + result, + "Some text\n\n\n1. Question\n> Answer\n\nMore text" + ); + } + + #[test] + fn given_content_when_writing_then_creates_file() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("output.md"); + + let content = "# Test\n\nSome content"; + + write_markdown_file(&file_path, content).unwrap(); + + assert!(file_path.exists()); + let written = fs::read_to_string(&file_path).unwrap(); + assert_eq!(written, content); + } + + #[test] + fn given_existing_file_when_writing_then_overwrites() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("output.md"); + + // Write initial content + fs::write(&file_path, "Old content").unwrap(); + + // Overwrite with new content + let new_content = "New content"; + write_markdown_file(&file_path, new_content).unwrap(); + + let written = fs::read_to_string(&file_path).unwrap(); + assert_eq!(written, new_content); + } + + #[test] + fn given_round_trip_when_reading_and_writing_then_preserves_content() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("roundtrip.md"); + + let original = r#"--- +Deck: Test + + +1. Question? +> Answer! + +2. Another question +> Another answer +---"#; + + // Write + write_markdown_file(&file_path, original).unwrap(); + + // Read back + let read_back = read_markdown_file(&file_path).unwrap(); + + assert_eq!(read_back, original); + } +} diff --git a/ankiview/src/inka/infrastructure/hasher.rs b/ankiview/src/inka/infrastructure/hasher.rs new file mode 100644 index 0000000..30c57b4 --- /dev/null +++ b/ankiview/src/inka/infrastructure/hasher.rs @@ -0,0 +1,321 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::Path; + +/// Calculate SHA256 hash of a file's content +pub fn calculate_file_hash(path: impl AsRef) -> Result { + let content = + std::fs::read_to_string(path.as_ref()).context("Failed to read file for hashing")?; + + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + let result = hasher.finalize(); + + // Convert to lowercase hex string + Ok(format!("{:x}", result)) +} + +/// Check if file content has changed by comparing hashes +pub fn has_file_changed(path: impl AsRef, previous_hash: &str) -> Result { + let current_hash = calculate_file_hash(path)?; + Ok(current_hash != previous_hash) +} + +/// Hash cache for tracking file changes +/// Stores filepath -> hash mapping in a JSON file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HashCache { + cache_path: std::path::PathBuf, + hashes: HashMap, +} + +impl HashCache { + /// Load hash cache from file, or create empty cache if file doesn't exist + pub fn load(path: impl AsRef) -> Result { + let cache_path = path.as_ref().to_path_buf(); + + let hashes = if cache_path.exists() { + let content = + std::fs::read_to_string(&cache_path).context("Failed to read hash cache file")?; + serde_json::from_str(&content).context("Failed to parse hash cache JSON")? + } else { + HashMap::new() + }; + + Ok(Self { cache_path, hashes }) + } + + /// Save hash cache to file + pub fn save(&self) -> Result<()> { + let json = + serde_json::to_string_pretty(&self.hashes).context("Failed to serialize hash cache")?; + + std::fs::write(&self.cache_path, json).context("Failed to write hash cache file")?; + + Ok(()) + } + + /// Check if file has changed compared to cached hash + /// Returns true if file is new or content has changed + pub fn file_has_changed(&self, filepath: impl AsRef) -> Result { + let path_str = filepath + .as_ref() + .to_str() + .ok_or_else(|| anyhow::anyhow!("Invalid file path"))? + .to_string(); + + // If not in cache, it's a new file (changed) + let Some(cached_hash) = self.hashes.get(&path_str) else { + return Ok(true); + }; + + // Compare current hash with cached hash + has_file_changed(filepath, cached_hash) + } + + /// Update hash for a file in the cache + pub fn update_hash(&mut self, filepath: impl AsRef) -> Result<()> { + let path_str = filepath + .as_ref() + .to_str() + .ok_or_else(|| anyhow::anyhow!("Invalid file path"))? + .to_string(); + + let hash = calculate_file_hash(filepath)?; + self.hashes.insert(path_str, hash); + + Ok(()) + } + + /// Clear all hashes from cache + pub fn clear(&mut self) { + self.hashes.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn given_file_when_calculating_hash_then_returns_sha256() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.md"); + fs::write(&file_path, "Hello, world!").unwrap(); + + let hash = calculate_file_hash(&file_path).unwrap(); + + // SHA256 hash should be 64 hex characters + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn given_same_content_when_calculating_hash_then_returns_same_value() { + let temp_dir = TempDir::new().unwrap(); + let file1 = temp_dir.path().join("file1.md"); + let file2 = temp_dir.path().join("file2.md"); + + let content = "Identical content"; + fs::write(&file1, content).unwrap(); + fs::write(&file2, content).unwrap(); + + let hash1 = calculate_file_hash(&file1).unwrap(); + let hash2 = calculate_file_hash(&file2).unwrap(); + + assert_eq!(hash1, hash2); + } + + #[test] + fn given_different_content_when_calculating_hash_then_returns_different_values() { + let temp_dir = TempDir::new().unwrap(); + let file1 = temp_dir.path().join("file1.md"); + let file2 = temp_dir.path().join("file2.md"); + + fs::write(&file1, "Content A").unwrap(); + fs::write(&file2, "Content B").unwrap(); + + let hash1 = calculate_file_hash(&file1).unwrap(); + let hash2 = calculate_file_hash(&file2).unwrap(); + + assert_ne!(hash1, hash2); + } + + #[test] + fn given_nonexistent_file_when_calculating_hash_then_returns_error() { + let result = calculate_file_hash("/nonexistent/file.md"); + + assert!(result.is_err()); + } + + #[test] + fn given_multiline_content_when_calculating_hash_then_handles_correctly() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("multi.md"); + + let content = "Line 1\nLine 2\nLine 3\n"; + fs::write(&file_path, content).unwrap(); + + let hash = calculate_file_hash(&file_path).unwrap(); + + // Should produce valid SHA256 hash + assert_eq!(hash.len(), 64); + } + + #[test] + fn given_matching_hash_when_checking_change_then_returns_false() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("unchanged.md"); + fs::write(&file_path, "Unchanged content").unwrap(); + + let current_hash = calculate_file_hash(&file_path).unwrap(); + let changed = has_file_changed(&file_path, ¤t_hash).unwrap(); + + assert!(!changed); + } + + #[test] + fn given_different_hash_when_checking_change_then_returns_true() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("changed.md"); + fs::write(&file_path, "New content").unwrap(); + + let old_hash = "0000000000000000000000000000000000000000000000000000000000000000"; + let changed = has_file_changed(&file_path, old_hash).unwrap(); + + assert!(changed); + } + + #[test] + fn given_file_modified_when_checking_then_detects_change() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("modified.md"); + + // Write initial content and get hash + fs::write(&file_path, "Original").unwrap(); + let original_hash = calculate_file_hash(&file_path).unwrap(); + + // Modify content + fs::write(&file_path, "Modified").unwrap(); + + // Should detect change + let changed = has_file_changed(&file_path, &original_hash).unwrap(); + assert!(changed); + } + + // HashCache tests + #[test] + fn given_nonexistent_cache_when_loading_then_creates_empty() { + let temp_dir = TempDir::new().unwrap(); + let cache_path = temp_dir.path().join("hashes.json"); + + let cache = HashCache::load(&cache_path).unwrap(); + + assert_eq!(cache.hashes.len(), 0); + } + + #[test] + fn given_cache_when_saving_then_creates_json_file() { + let temp_dir = TempDir::new().unwrap(); + let cache_path = temp_dir.path().join("cache.json"); + + let cache = HashCache::load(&cache_path).unwrap(); + cache.save().unwrap(); + + assert!(cache_path.exists()); + let content = fs::read_to_string(&cache_path).unwrap(); + assert!(content.contains("{") && content.contains("}")); + } + + #[test] + fn given_new_file_when_checking_then_returns_changed() { + let temp_dir = TempDir::new().unwrap(); + let cache_path = temp_dir.path().join("cache.json"); + let file_path = temp_dir.path().join("test.md"); + fs::write(&file_path, "Content").unwrap(); + + let cache = HashCache::load(&cache_path).unwrap(); + let changed = cache.file_has_changed(&file_path).unwrap(); + + assert!(changed); + } + + #[test] + fn given_unchanged_file_when_checking_then_returns_false() { + let temp_dir = TempDir::new().unwrap(); + let cache_path = temp_dir.path().join("cache.json"); + let file_path = temp_dir.path().join("unchanged.md"); + fs::write(&file_path, "Stable content").unwrap(); + + let mut cache = HashCache::load(&cache_path).unwrap(); + cache.update_hash(&file_path).unwrap(); + cache.save().unwrap(); + + // Reload cache and check same file + let cache = HashCache::load(&cache_path).unwrap(); + let changed = cache.file_has_changed(&file_path).unwrap(); + + assert!(!changed); + } + + #[test] + fn given_modified_file_when_checking_then_returns_changed() { + let temp_dir = TempDir::new().unwrap(); + let cache_path = temp_dir.path().join("cache.json"); + let file_path = temp_dir.path().join("modified.md"); + fs::write(&file_path, "Original").unwrap(); + + let mut cache = HashCache::load(&cache_path).unwrap(); + cache.update_hash(&file_path).unwrap(); + cache.save().unwrap(); + + // Modify file + fs::write(&file_path, "Modified").unwrap(); + + // Reload and check + let cache = HashCache::load(&cache_path).unwrap(); + let changed = cache.file_has_changed(&file_path).unwrap(); + + assert!(changed); + } + + #[test] + fn given_cache_with_hashes_when_clearing_then_removes_all() { + let temp_dir = TempDir::new().unwrap(); + let cache_path = temp_dir.path().join("cache.json"); + let file_path = temp_dir.path().join("file.md"); + fs::write(&file_path, "Content").unwrap(); + + let mut cache = HashCache::load(&cache_path).unwrap(); + cache.update_hash(&file_path).unwrap(); + assert_eq!(cache.hashes.len(), 1); + + cache.clear(); + assert_eq!(cache.hashes.len(), 0); + } + + #[test] + fn given_multiple_files_when_updating_then_tracks_all() { + let temp_dir = TempDir::new().unwrap(); + let cache_path = temp_dir.path().join("cache.json"); + let file1 = temp_dir.path().join("file1.md"); + let file2 = temp_dir.path().join("file2.md"); + fs::write(&file1, "Content 1").unwrap(); + fs::write(&file2, "Content 2").unwrap(); + + let mut cache = HashCache::load(&cache_path).unwrap(); + cache.update_hash(&file1).unwrap(); + cache.update_hash(&file2).unwrap(); + cache.save().unwrap(); + + // Reload and verify both tracked + let cache = HashCache::load(&cache_path).unwrap(); + assert_eq!(cache.hashes.len(), 2); + assert!(!cache.file_has_changed(&file1).unwrap()); + assert!(!cache.file_has_changed(&file2).unwrap()); + } +} diff --git a/ankiview/src/inka/infrastructure/markdown/card_parser.rs b/ankiview/src/inka/infrastructure/markdown/card_parser.rs new file mode 100644 index 0000000..3c87701 --- /dev/null +++ b/ankiview/src/inka/infrastructure/markdown/card_parser.rs @@ -0,0 +1,253 @@ +use anyhow::Result; +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + static ref BASIC_CARD_REGEX: Regex = + Regex::new(r"(?m)(?:^\n)?^\d+\.[\s\S]+?(?:^>.*?(?:\n|$))+") + .expect("Failed to compile basic card regex"); + static ref ID_REGEX: Regex = + Regex::new(r"(?m)^$").expect("Failed to compile ID regex"); +} + +pub fn is_basic_card(note_str: &str) -> bool { + BASIC_CARD_REGEX.is_match(note_str) +} + +pub fn is_cloze_card(note_str: &str) -> bool { + // A cloze card has curly braces (for cloze deletions) + // and doesn't have the answer marker (>) + note_str.contains('{') + && !note_str + .lines() + .any(|line| line.trim_start().starts_with('>')) +} + +pub fn parse_basic_card_fields(note_str: &str) -> Result<(String, String)> { + // Find the first line with a number and dot + let lines: Vec<&str> = note_str.lines().collect(); + let mut question_lines = Vec::new(); + let mut answer_lines = Vec::new(); + let mut in_answer = false; + + for line in lines { + let trimmed = line.trim(); + + // Skip ID comments + if trimmed.starts_with("\n1. Question\n> Answer"; + let (front, back) = parse_basic_card_fields(note_str).unwrap(); + + assert_eq!(front, "Question"); + assert_eq!(back, "Answer"); + } + + #[test] + fn given_note_without_answer_when_parsing_then_returns_error() { + let note_str = "1. Only question"; + let result = parse_basic_card_fields(note_str); + + assert!(result.is_err()); + } + + #[test] + fn given_cloze_note_string_when_parsing_then_extracts_text() { + let note_str = "1. Paris is the {{c1::capital}} of {{c2::France}}"; + let text = parse_cloze_card_field(note_str).unwrap(); + + assert_eq!(text, "Paris is the {{c1::capital}} of {{c2::France}}"); + } + + #[test] + fn given_cloze_with_id_when_parsing_then_excludes_id() { + let note_str = "\n1. Text {{c1::cloze}}"; + let text = parse_cloze_card_field(note_str).unwrap(); + + assert_eq!(text, "Text {{c1::cloze}}"); + } + + #[test] + fn given_cloze_with_short_syntax_when_parsing_then_extracts() { + let note_str = "1. Capital is {Paris}"; + let text = parse_cloze_card_field(note_str).unwrap(); + + assert_eq!(text, "Capital is {Paris}"); + } + + #[test] + fn given_note_with_id_when_parsing_then_extracts_id() { + let note_str = "\n1. Question?"; + let id = extract_anki_id(note_str); + + assert_eq!(id, Some(1234567890)); + } + + #[test] + fn given_note_without_id_when_parsing_then_returns_none() { + let note_str = "1. Question?"; + let id = extract_anki_id(note_str); + + assert_eq!(id, None); + } + + #[test] + fn given_note_with_invalid_id_when_parsing_then_returns_none() { + let note_str = "\n1. Q"; + let id = extract_anki_id(note_str); + + assert_eq!(id, None); + } +} diff --git a/ankiview/src/inka/infrastructure/markdown/cloze_converter.rs b/ankiview/src/inka/infrastructure/markdown/cloze_converter.rs new file mode 100644 index 0000000..58b0e5f --- /dev/null +++ b/ankiview/src/inka/infrastructure/markdown/cloze_converter.rs @@ -0,0 +1,240 @@ +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + static ref ANKI_CLOZE_REGEX: Regex = + Regex::new(r"\{\{c\d+::[\s\S]*?\}\}").expect("Failed to compile Anki cloze regex"); + static ref EXPLICIT_SHORT_CLOZE_REGEX: Regex = Regex::new(r"\{c?(\d+)::([\s\S]*?)\}") + .expect("Failed to compile explicit short cloze regex"); + static ref IMPLICIT_SHORT_CLOZE_REGEX: Regex = + Regex::new(r"\{([\s\S]*?)\}").expect("Failed to compile implicit short cloze regex"); + static ref CODE_BLOCK_REGEX: Regex = + Regex::new(r"```[\s\S]+?```").expect("Failed to compile code block regex"); + static ref INLINE_CODE_REGEX: Regex = + Regex::new(r"`[\S\s]+?`").expect("Failed to compile inline code regex"); + static ref BLOCK_MATH_REGEX: Regex = + Regex::new(r"\$\$[\s\S]+?\$\$").expect("Failed to compile block math regex"); + static ref INLINE_MATH_REGEX: Regex = + Regex::new(r"\$[^\s$][^$]*?\$").expect("Failed to compile inline math regex"); +} + +pub fn is_anki_cloze(text: &str) -> bool { + ANKI_CLOZE_REGEX.is_match(text) +} + +pub fn convert_cloze_syntax(text: &str) -> String { + // Protect code and math blocks + let (text, code_blocks) = protect_code_blocks(text); + let (text, math_blocks) = protect_math_blocks(&text); + + // Find all cloze-like patterns + let mut result = text.clone(); + let mut counter = 1; + + // Process each potential cloze deletion + let all_clozes: Vec<_> = find_all_clozes(&text); + + for cloze in all_clozes { + if is_anki_cloze(&cloze) { + // Already in Anki format, skip + continue; + } + + // Try explicit short syntax: {1::text} or {c1::text} + if let Some(caps) = EXPLICIT_SHORT_CLOZE_REGEX.captures(&cloze) { + let index = caps.get(1).unwrap().as_str(); + let content = caps.get(2).unwrap().as_str(); + let replacement = format!("{{{{c{}::{}}}}}", index, content); + result = result.replacen(&cloze, &replacement, 1); + continue; + } + + // Try implicit short syntax: {text} + if let Some(caps) = IMPLICIT_SHORT_CLOZE_REGEX.captures(&cloze) { + let content = caps.get(1).unwrap().as_str(); + let replacement = format!("{{{{c{}::{}}}}}", counter, content); + result = result.replacen(&cloze, &replacement, 1); + counter += 1; + } + } + + // Restore protected blocks + let result = restore_math_blocks(&result, math_blocks); + restore_code_blocks(&result, code_blocks) +} + +fn find_all_clozes(text: &str) -> Vec { + // Find all {...} patterns that aren't already {{c...}} + let mut clozes = Vec::new(); + let mut chars = text.chars().peekable(); + let mut current = String::new(); + let mut in_cloze = false; + let mut brace_count = 0; + + while let Some(c) = chars.next() { + if c == '{' { + if chars.peek() == Some(&'{') { + // Skip Anki format + current.push(c); + current.push(chars.next().unwrap()); + continue; + } + in_cloze = true; + brace_count = 1; + current.push(c); + } else if c == '}' && in_cloze { + current.push(c); + brace_count -= 1; + if brace_count == 0 { + clozes.push(current.clone()); + current.clear(); + in_cloze = false; + } + } else if in_cloze { + current.push(c); + if c == '{' { + brace_count += 1; + } + } + } + + clozes +} + +fn protect_code_blocks(text: &str) -> (String, Vec) { + let mut blocks = Vec::new(); + let mut result = text.to_string(); + + // Block code first (must come before inline) + for mat in CODE_BLOCK_REGEX.find_iter(text) { + blocks.push(mat.as_str().to_string()); + } + result = CODE_BLOCK_REGEX + .replace_all(&result, "___CODE_BLOCK___") + .to_string(); + + // Inline code + for mat in INLINE_CODE_REGEX.find_iter(&result) { + blocks.push(mat.as_str().to_string()); + } + result = INLINE_CODE_REGEX + .replace_all(&result, "___INLINE_CODE___") + .to_string(); + + (result, blocks) +} + +fn protect_math_blocks(text: &str) -> (String, Vec) { + let mut blocks = Vec::new(); + let mut result = text.to_string(); + + // Block math first (MUST come before inline to avoid matching $$ as two $ markers) + for mat in BLOCK_MATH_REGEX.find_iter(text) { + blocks.push(mat.as_str().to_string()); + } + result = BLOCK_MATH_REGEX + .replace_all(&result, "___MATH_BLOCK___") + .to_string(); + + // Inline math - now the $$ are already protected + for mat in INLINE_MATH_REGEX.find_iter(&result) { + blocks.push(mat.as_str().to_string()); + } + result = INLINE_MATH_REGEX + .replace_all(&result, "___INLINE_MATH___") + .to_string(); + + (result, blocks) +} + +fn restore_code_blocks(text: &str, blocks: Vec) -> String { + let mut result = text.to_string(); + for block in blocks { + if result.contains("___CODE_BLOCK___") { + result = result.replacen("___CODE_BLOCK___", &block, 1); + } else if result.contains("___INLINE_CODE___") { + result = result.replacen("___INLINE_CODE___", &block, 1); + } + } + result +} + +fn restore_math_blocks(text: &str, blocks: Vec) -> String { + let mut result = text.to_string(); + for block in blocks { + if result.contains("___MATH_BLOCK___") { + result = result.replacen("___MATH_BLOCK___", &block, 1); + } else if result.contains("___INLINE_MATH___") { + result = result.replacen("___INLINE_MATH___", &block, 1); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn given_anki_format_cloze_when_checking_then_returns_true() { + assert!(is_anki_cloze("{{c1::text}}")); + assert!(is_anki_cloze("{{c12::multiple words}}")); + assert!(!is_anki_cloze("{1::text}")); + assert!(!is_anki_cloze("{text}")); + } + + #[test] + fn given_explicit_short_cloze_when_converting_then_transforms_to_anki() { + let input = "Text {1::hidden} more {c2::also}"; + let output = convert_cloze_syntax(input); + + assert_eq!(output, "Text {{c1::hidden}} more {{c2::also}}"); + } + + #[test] + fn given_already_anki_format_when_converting_then_unchanged() { + let input = "Text {{c1::already}} correct"; + let output = convert_cloze_syntax(input); + + assert_eq!(output, "Text {{c1::already}} correct"); + } + + #[test] + fn given_implicit_short_cloze_when_converting_then_numbers_sequentially() { + let input = "First {one} then {two} finally {three}"; + let output = convert_cloze_syntax(input); + + assert_eq!( + output, + "First {{c1::one}} then {{c2::two}} finally {{c3::three}}" + ); + } + + #[test] + fn given_cloze_with_code_block_when_converting_then_preserves_code() { + let input = "Text {answer}\n```\n{not_a_cloze}\n```"; + let output = convert_cloze_syntax(input); + + assert_eq!(output, "Text {{c1::answer}}\n```\n{not_a_cloze}\n```"); + } + + #[test] + fn given_cloze_with_inline_code_when_converting_then_preserves_code() { + let input = "Text {answer} and `code {with braces}`"; + let output = convert_cloze_syntax(input); + + assert!(output.contains("{{c1::answer}}")); + assert!(output.contains("`code {with braces}`")); + } + + #[test] + fn given_cloze_with_math_when_converting_then_preserves_math() { + let input = "Equation {answer} is $$x^{2}$$ and inline $y^{3}$"; + let output = convert_cloze_syntax(input); + + assert_eq!( + output, + "Equation {{c1::answer}} is $$x^{2}$$ and inline $y^{3}$" + ); + } +} diff --git a/ankiview/src/inka/infrastructure/markdown/converter.rs b/ankiview/src/inka/infrastructure/markdown/converter.rs new file mode 100644 index 0000000..dfdbd90 --- /dev/null +++ b/ankiview/src/inka/infrastructure/markdown/converter.rs @@ -0,0 +1,88 @@ +use super::mathjax_plugin::add_mathjax_plugin; +use lazy_static::lazy_static; +use markdown_it::MarkdownIt; +use regex::Regex; + +lazy_static! { + static ref NEWLINE_TAG_REGEX: Regex = + Regex::new(r"\n?(<.+?>)\n?").expect("Failed to compile newline tag regex"); +} + +pub fn markdown_to_html(text: &str) -> String { + let mut parser = MarkdownIt::new(); + markdown_it::plugins::cmark::add(&mut parser); + markdown_it::plugins::extra::add(&mut parser); + add_mathjax_plugin(&mut parser); + + let html = parser.parse(text).render(); + + // Remove newlines around HTML tags (Anki rendering quirk) + remove_newlines_around_tags(&html) +} + +fn remove_newlines_around_tags(html: &str) -> String { + NEWLINE_TAG_REGEX.replace_all(html, "$1").to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn given_markdown_text_when_converting_then_renders_html() { + let input = "**bold** and *italic*"; + let html = markdown_to_html(input); + + assert!(html.contains("bold")); + assert!(html.contains("italic")); + } + + #[test] + fn given_markdown_with_newlines_around_tags_when_converting_then_removes_them() { + // Anki quirk: newlines around HTML tags render as visible breaks + let input = "Text\n\n**bold**\n\nMore"; + let html = markdown_to_html(input); + + // Should not have \n or \n + assert!(!html.contains("\n<")); + assert!(!html.contains(">\n")); + } + + #[test] + fn given_markdown_with_math_when_converting_then_uses_mathjax_delimiters() { + let input = "Inline $f(x)$ and block:\n$$\ng(x)\n$$"; + let html = markdown_to_html(input); + + assert!(html.contains(r"\(f(x)\)")); + assert!(html.contains(r"\[g(x)\]")); + } + + #[test] + fn given_complex_math_when_converting_then_preserves_latex() { + let input = r"$$ +\sum_{i=1}^{n} i = \frac{n(n+1)}{2} +$$"; + let html = markdown_to_html(input); + + assert!(html.contains(r"\[\sum_{i=1}^{n}")); + } + + #[test] + fn given_code_block_when_converting_then_preserves_for_highlightjs() { + let input = "```rust\nfn main() {}\n```"; + let html = markdown_to_html(input); + + // markdown-it extra plugin uses syntect for syntax highlighting with inline styles + assert!(html.contains("inline code")); + } +} diff --git a/ankiview/src/inka/infrastructure/markdown/mathjax_plugin.rs b/ankiview/src/inka/infrastructure/markdown/mathjax_plugin.rs new file mode 100644 index 0000000..a281f79 --- /dev/null +++ b/ankiview/src/inka/infrastructure/markdown/mathjax_plugin.rs @@ -0,0 +1,172 @@ +use markdown_it::parser::block::{BlockRule, BlockState}; +use markdown_it::parser::inline::{InlineRule, InlineState}; +use markdown_it::{MarkdownIt, Node, NodeValue, Renderer}; + +#[derive(Debug)] +pub struct InlineMath { + pub content: String, +} + +impl NodeValue for InlineMath { + fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { + // Render as \(...\) for MathJax + fmt.text(&format!(r"\({}\)", self.content)); + } +} + +struct InlineMathScanner; + +impl InlineRule for InlineMathScanner { + const MARKER: char = '$'; + + fn run(state: &mut InlineState) -> Option<(Node, usize)> { + let input = &state.src[state.pos..state.pos_max]; + + // Check if we start with $ + if !input.starts_with('$') { + return None; + } + + // Don't match if $ is followed by whitespace + if input.len() < 2 || input.chars().nth(1)?.is_whitespace() { + return None; + } + + // Find the closing $ + let mut end_pos = None; + let chars: Vec = input.chars().collect(); + + for i in 1..chars.len() { + if chars[i] == '$' { + // Don't match if $ is preceded by whitespace + if i > 0 && !chars[i - 1].is_whitespace() { + end_pos = Some(i); + break; + } + } + } + + if let Some(end) = end_pos { + // Extract content between the $...$ (excluding the $ markers) + let content: String = chars[1..end].iter().collect(); + let match_len = end + 1; // Include both $ markers + + let node = Node::new(InlineMath { content }); + return Some((node, match_len)); + } + + None + } +} + +#[derive(Debug)] +pub struct BlockMath { + pub content: String, +} + +impl NodeValue for BlockMath { + fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { + // Render as \[...\] for MathJax + fmt.text(&format!(r"\[{}\]", self.content)); + } +} + +struct BlockMathScanner; + +impl BlockRule for BlockMathScanner { + fn run(state: &mut BlockState) -> Option<(Node, usize)> { + // Get the current line + if state.line >= state.line_max { + return None; + } + + let start_line = state.line; + let line = state.get_line(start_line); + + // Check if line starts with $$ + if !line.trim().starts_with("$$") { + return None; + } + + // Find the closing $$ + let mut end_line = None; + for line_num in (start_line + 1)..state.line_max { + let line = state.get_line(line_num); + if line.trim().starts_with("$$") { + end_line = Some(line_num); + break; + } + } + + if let Some(end) = end_line { + // Extract content between the $$ markers + let mut content_lines = Vec::new(); + for line_num in (start_line + 1)..end { + content_lines.push(state.get_line(line_num).to_string()); + } + let content = content_lines.join("\n"); + + let node = Node::new(BlockMath { content }); + // Return the closing $$ line - the parser will advance past it + return Some((node, end)); + } + + None + } +} + +pub fn add_mathjax_plugin(md: &mut MarkdownIt) { + md.inline.add_rule::(); + md.block.add_rule::().before_all(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn given_inline_math_when_parsing_then_creates_math_token() { + let input = "This is $f(x) = x^2$ inline math"; + let mut parser = MarkdownIt::new(); + markdown_it::plugins::cmark::add(&mut parser); + add_mathjax_plugin(&mut parser); + + let ast = parser.parse(input); + let html = ast.render(); + + // Should render with MathJax delimiters + assert!(html.contains(r"\(f(x) = x^2\)")); + } + + #[test] + fn given_block_math_when_parsing_then_creates_block_math_token() { + let input = "$$\nf(x) = \\int_0^1 x^2 dx\n$$"; + let mut parser = MarkdownIt::new(); + markdown_it::plugins::cmark::add(&mut parser); + add_mathjax_plugin(&mut parser); + + let html = parser.parse(input).render(); + + assert!(html.contains(r"\[f(x) = \int_0^1 x^2 dx\]")); + } + + #[test] + fn given_mixed_math_when_parsing_then_handles_both_types() { + let input = r#"Inline $a=b$ and block: + +$$ +\sum_{i=1}^n i = \frac{n(n+1)}{2} +$$ + +More text."#; + + let mut parser = MarkdownIt::new(); + markdown_it::plugins::cmark::add(&mut parser); + add_mathjax_plugin(&mut parser); + + let html = parser.parse(input).render(); + + assert!(html.contains(r"\(a=b\)")); + assert!(html.contains(r"\[\sum_{i=1}^n i = \frac{n(n+1)}{2}\]")); + } +} diff --git a/ankiview/src/inka/infrastructure/markdown/mod.rs b/ankiview/src/inka/infrastructure/markdown/mod.rs new file mode 100644 index 0000000..dc922f8 --- /dev/null +++ b/ankiview/src/inka/infrastructure/markdown/mod.rs @@ -0,0 +1,6 @@ +// Markdown processing module +pub mod card_parser; +pub mod cloze_converter; +pub mod converter; +pub mod mathjax_plugin; +pub mod section_parser; diff --git a/ankiview/src/inka/infrastructure/markdown/section_parser.rs b/ankiview/src/inka/infrastructure/markdown/section_parser.rs new file mode 100644 index 0000000..7688c8c --- /dev/null +++ b/ankiview/src/inka/infrastructure/markdown/section_parser.rs @@ -0,0 +1,241 @@ +use lazy_static::lazy_static; +use regex::Regex; + +pub struct SectionParser { + section_regex: Regex, +} + +impl SectionParser { + pub fn new() -> Self { + // Regex pattern: ^---\n(.+?)^---$ + // Multiline and dotall flags + let section_regex = + Regex::new(r"(?ms)^---\n(.+?)^---$").expect("Failed to compile section regex"); + + Self { section_regex } + } + + pub fn parse<'a>(&self, input: &'a str) -> Vec<&'a str> { + self.section_regex + .captures_iter(input) + .filter_map(|cap| cap.get(1)) + .map(|m| m.as_str()) + .collect() + } +} + +impl Default for SectionParser { + fn default() -> Self { + Self::new() + } +} + +lazy_static! { + static ref DECK_REGEX: Regex = + Regex::new(r"(?m)^Deck:[ \t]*(.+?)$").expect("Failed to compile deck regex"); + static ref TAGS_REGEX: Regex = + Regex::new(r"(?m)^Tags:[ \t]*(.+?)$").expect("Failed to compile tags regex"); + static ref NOTE_START_REGEX: Regex = + Regex::new(r"(?m)^(?:\n)?^\d+\.").expect("Failed to compile note start regex"); +} + +pub fn extract_deck_name(section: &str) -> Option { + DECK_REGEX + .captures(section) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().trim().to_string()) +} + +pub fn extract_tags(section: &str) -> Vec { + TAGS_REGEX + .captures(section) + .and_then(|cap| cap.get(1)) + .map(|m| { + m.as_str() + .split_whitespace() + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_default() +} + +pub fn extract_note_strings(section: &str) -> Vec { + // Find all positions where notes start (either "1. " or "\n1. ") + let mut note_positions: Vec = Vec::new(); + + // Find all lines starting with digits followed by a dot + for line in section.lines() { + if let Some(trimmed) = line.trim_start().strip_prefix(|c: char| c.is_ascii_digit()) { + if trimmed.starts_with('.') { + // Found a note start, get its position in the original string + if let Some(pos) = section.find(line) { + // Check if there's an ID comment before this line + let before = §ion[..pos]; + if let Some(last_line) = before.lines().last() { + if last_line.trim().starts_with("\n1. Q1\n> A1\n\n2. Q2\n> A2"; + let notes = extract_note_strings(section); + + assert_eq!(notes.len(), 2); + assert!(notes[0].contains("")); + assert!(notes[1].contains("")); + } + + #[test] + fn given_section_with_cloze_and_basic_when_extracting_then_finds_both() { + let section = "1. Basic Q\n> Basic A\n2. Cloze {{c1::text}}"; + let notes = extract_note_strings(section); + + assert_eq!(notes.len(), 2); + } +} diff --git a/ankiview/src/inka/infrastructure/media_handler.rs b/ankiview/src/inka/infrastructure/media_handler.rs new file mode 100644 index 0000000..570af18 --- /dev/null +++ b/ankiview/src/inka/infrastructure/media_handler.rs @@ -0,0 +1,420 @@ +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + // Match markdown images: ![alt](path) + static ref MD_IMAGE_REGEX: Regex = Regex::new(r"!\[.*?\]\(([^)]+)\)") + .expect("Failed to compile markdown image regex"); + + // Match HTML img tags: + static ref HTML_IMAGE_REGEX: Regex = Regex::new(r#"]+src="([^"]+)""#) + .expect("Failed to compile HTML image regex"); +} + +/// Extract image paths from markdown content +/// Supports both markdown syntax ![alt](path) and HTML +pub fn extract_image_paths(markdown: &str) -> Vec { + let mut paths = Vec::new(); + + // Extract markdown format images + for cap in MD_IMAGE_REGEX.captures_iter(markdown) { + if let Some(path_match) = cap.get(1) { + let path = path_match.as_str(); + // Skip HTTP(S) URLs + if !path.starts_with("http://") && !path.starts_with("https://") { + paths.push(path.to_string()); + } + } + } + + // Extract HTML format images + for cap in HTML_IMAGE_REGEX.captures_iter(markdown) { + if let Some(path_match) = cap.get(1) { + let path = path_match.as_str(); + // Skip HTTP(S) URLs + if !path.starts_with("http://") && !path.starts_with("https://") { + paths.push(path.to_string()); + } + } + } + + paths +} + +/// Copy a media file to Anki's collection.media directory +/// Returns the filename (not full path) that Anki will use +pub fn copy_media_to_anki( + source_path: &std::path::Path, + media_dir: &std::path::Path, + force: bool, +) -> anyhow::Result { + use anyhow::Context; + + // Extract filename from source path + let filename = source_path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| anyhow::anyhow!("Invalid filename"))?; + + let dest_path = media_dir.join(filename); + + // Check if file exists in media directory + if dest_path.exists() { + // Use filecmp equivalent - compare file contents + let files_identical = files_are_identical(source_path, &dest_path) + .context("Failed to compare file contents")?; + + if files_identical { + // Same file already exists - optimization, skip copy + return Ok(filename.to_string()); + } + + // Files have different content + if !force { + // Error on conflict without --force + return Err(anyhow::anyhow!( + "Different file with the same name \"{}\" already exists in Anki Media folder. \ + Use --force to overwrite.", + filename + )); + } + + // force=true: overwrite existing file + } + + // Copy file (either new or force overwrite) + std::fs::copy(source_path, &dest_path).context("Failed to copy media file")?; + + Ok(filename.to_string()) +} + +/// Compare two files for identical content +fn files_are_identical(path1: &std::path::Path, path2: &std::path::Path) -> anyhow::Result { + use std::io::Read; + + let mut file1 = std::fs::File::open(path1)?; + let mut file2 = std::fs::File::open(path2)?; + + // Quick size check first + let meta1 = file1.metadata()?; + let meta2 = file2.metadata()?; + + if meta1.len() != meta2.len() { + return Ok(false); + } + + // Compare contents byte by byte + let mut buf1 = Vec::new(); + let mut buf2 = Vec::new(); + + file1.read_to_end(&mut buf1)?; + file2.read_to_end(&mut buf2)?; + + Ok(buf1 == buf2) +} + +/// Update image paths in HTML to use Anki media filenames +/// Takes a mapping of original paths to Anki filenames +pub fn update_media_paths_in_html( + html: &str, + path_mapping: &std::collections::HashMap, +) -> String { + let mut result = html.to_string(); + + // Replace each original path with its Anki filename + for (original_path, anki_filename) in path_mapping { + result = result.replace(original_path, anki_filename); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn given_markdown_image_when_extracting_then_returns_path() { + let markdown = "Some text ![alt text](images/photo.png) more text"; + let paths = extract_image_paths(markdown); + + assert_eq!(paths, vec!["images/photo.png"]); + } + + #[test] + fn given_multiple_images_when_extracting_then_returns_all_paths() { + let markdown = r#" +![First](image1.png) +Some text +![Second](path/to/image2.jpg) +More text +![Third](../relative/image3.gif) +"#; + let paths = extract_image_paths(markdown); + + assert_eq!(paths.len(), 3); + assert!(paths.contains(&"image1.png".to_string())); + assert!(paths.contains(&"path/to/image2.jpg".to_string())); + assert!(paths.contains(&"../relative/image3.gif".to_string())); + } + + #[test] + fn given_html_img_tag_when_extracting_then_returns_path() { + let markdown = r#"Some text more text"#; + let paths = extract_image_paths(markdown); + + assert_eq!(paths, vec!["diagrams/flow.png"]); + } + + #[test] + fn given_mixed_formats_when_extracting_then_returns_all() { + let markdown = r#" +Markdown: ![logo](logo.png) +HTML: +Another: ![icon](icons/star.svg) +"#; + let paths = extract_image_paths(markdown); + + assert_eq!(paths.len(), 3); + assert!(paths.contains(&"logo.png".to_string())); + assert!(paths.contains(&"banner.jpg".to_string())); + assert!(paths.contains(&"icons/star.svg".to_string())); + } + + #[test] + fn given_no_images_when_extracting_then_returns_empty() { + let markdown = "Just text with no images at all"; + let paths = extract_image_paths(markdown); + + assert!(paths.is_empty()); + } + + #[test] + fn given_absolute_urls_when_extracting_then_excludes_them() { + let markdown = r#" +Local: ![local](image.png) +HTTP: ![remote](http://example.com/image.jpg) +HTTPS: ![secure](https://example.com/photo.png) +"#; + let paths = extract_image_paths(markdown); + + // Should only return local path, not HTTP(S) URLs + assert_eq!(paths, vec!["image.png"]); + } + + #[test] + fn given_source_file_when_copying_then_file_appears_in_media_dir() { + use std::fs; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let source_file = temp_dir.path().join("test_image.png"); + fs::write(&source_file, b"fake image data").unwrap(); + + let media_dir = temp_dir.path().join("collection.media"); + fs::create_dir(&media_dir).unwrap(); + + let filename = copy_media_to_anki(&source_file, &media_dir, false).unwrap(); + + // Should return just the filename + assert_eq!(filename, "test_image.png"); + + // File should exist in media directory + let dest_path = media_dir.join(&filename); + assert!(dest_path.exists()); + + // Content should match + let content = fs::read(&dest_path).unwrap(); + assert_eq!(content, b"fake image data"); + } + + #[test] + fn given_identical_file_when_copying_without_force_then_skips() { + use std::fs; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let source_file = temp_dir.path().join("image.png"); + fs::write(&source_file, b"same content").unwrap(); + + let media_dir = temp_dir.path().join("collection.media"); + fs::create_dir(&media_dir).unwrap(); + + // Pre-create identical file in media dir + let existing_file = media_dir.join("image.png"); + fs::write(&existing_file, b"same content").unwrap(); + + // Copy should succeed and return filename + let filename = copy_media_to_anki(&source_file, &media_dir, false).unwrap(); + assert_eq!(filename, "image.png"); + + // Should not overwrite (content stays same but we verify no error) + let content = fs::read(&existing_file).unwrap(); + assert_eq!(content, b"same content"); + } + + #[test] + fn given_different_file_when_copying_without_force_then_errors() { + use std::fs; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let source_file = temp_dir.path().join("image.png"); + fs::write(&source_file, b"new content").unwrap(); + + let media_dir = temp_dir.path().join("collection.media"); + fs::create_dir(&media_dir).unwrap(); + + // Pre-create different file in media dir + let existing_file = media_dir.join("image.png"); + fs::write(&existing_file, b"old content").unwrap(); + + // Copy should fail with error about conflict + let result = copy_media_to_anki(&source_file, &media_dir, false); + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("already exists")); + assert!(error_msg.contains("--force")); + } + + #[test] + fn given_different_file_when_copying_with_force_then_overwrites() { + use std::fs; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let source_file = temp_dir.path().join("image.png"); + fs::write(&source_file, b"new content").unwrap(); + + let media_dir = temp_dir.path().join("collection.media"); + fs::create_dir(&media_dir).unwrap(); + + // Pre-create different file in media dir + let existing_file = media_dir.join("image.png"); + fs::write(&existing_file, b"old content").unwrap(); + + // Copy with force should succeed + let filename = copy_media_to_anki(&source_file, &media_dir, true).unwrap(); + assert_eq!(filename, "image.png"); + + // Should overwrite with new content + let content = fs::read(&existing_file).unwrap(); + assert_eq!(content, b"new content"); + } + + #[test] + fn given_nonexistent_source_when_copying_then_returns_error() { + use std::fs; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let nonexistent = temp_dir.path().join("doesnt_exist.png"); + + let media_dir = temp_dir.path().join("collection.media"); + fs::create_dir(&media_dir).unwrap(); + + let result = copy_media_to_anki(&nonexistent, &media_dir, false); + assert!(result.is_err()); + } + + #[test] + fn given_file_with_path_when_copying_then_returns_basename() { + use std::fs; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let subdir = temp_dir.path().join("images"); + fs::create_dir(&subdir).unwrap(); + + let source_file = subdir.join("photo.jpg"); + fs::write(&source_file, b"photo data").unwrap(); + + let media_dir = temp_dir.path().join("collection.media"); + fs::create_dir(&media_dir).unwrap(); + + let filename = copy_media_to_anki(&source_file, &media_dir, false).unwrap(); + + // Should return just filename, not path + assert_eq!(filename, "photo.jpg"); + + // File should be in media dir root (not in subdirectory) + assert!(media_dir.join("photo.jpg").exists()); + } + + #[test] + fn given_html_with_image_src_when_updating_then_replaces_path() { + use std::collections::HashMap; + + let html = r#"

Some text Photo more text

"#; + let mut mapping = HashMap::new(); + mapping.insert("images/photo.png".to_string(), "photo.png".to_string()); + + let updated = update_media_paths_in_html(html, &mapping); + + assert!(updated.contains(r#" +

Text

+ + "#; + + let mut mapping = HashMap::new(); + mapping.insert("path/to/image1.jpg".to_string(), "image1.jpg".to_string()); + mapping.insert("another/image2.png".to_string(), "image2.png".to_string()); + + let updated = update_media_paths_in_html(html, &mapping); + + assert!(updated.contains(r#"src="image1.jpg""#)); + assert!(updated.contains(r#"src="image2.png""#)); + assert!(!updated.contains("path/to/")); + assert!(!updated.contains("another/")); + } + + #[test] + fn given_html_with_no_matching_paths_when_updating_then_unchanged() { + use std::collections::HashMap; + + let html = r#"

Text without images

"#; + let mapping = HashMap::new(); + + let updated = update_media_paths_in_html(html, &mapping); + + assert_eq!(updated, html); + } + + #[test] + fn given_html_with_unmapped_image_when_updating_then_leaves_unchanged() { + use std::collections::HashMap; + + let html = r#" and "#; + let mut mapping = HashMap::new(); + mapping.insert("mapped.jpg".to_string(), "new_mapped.jpg".to_string()); + + let updated = update_media_paths_in_html(html, &mapping); + + // Should update only mapped path + assert!(updated.contains(r#"src="new_mapped.jpg""#)); + // Should leave unmapped path as-is + assert!(updated.contains(r#"src="unmapped.png""#)); + } + + #[test] + fn given_markdown_img_syntax_when_updating_then_replaces_path() { + use std::collections::HashMap; + + let html = r#"

Diagram

"#; + let mut mapping = HashMap::new(); + mapping.insert("images/diagram.png".to_string(), "diagram.png".to_string()); + + let updated = update_media_paths_in_html(html, &mapping); + + assert!(updated.contains(r#"src="diagram.png""#)); + } +} diff --git a/ankiview/src/inka/infrastructure/mod.rs b/ankiview/src/inka/infrastructure/mod.rs new file mode 100644 index 0000000..2cdc1d8 --- /dev/null +++ b/ankiview/src/inka/infrastructure/mod.rs @@ -0,0 +1,5 @@ +pub mod config; +pub mod file_writer; +pub mod hasher; +pub mod markdown; +pub mod media_handler; diff --git a/ankiview/src/inka/mod.rs b/ankiview/src/inka/mod.rs new file mode 100644 index 0000000..6cc80f2 --- /dev/null +++ b/ankiview/src/inka/mod.rs @@ -0,0 +1,4 @@ +pub mod application; +pub mod cli; +pub mod domain; +pub mod infrastructure; diff --git a/ankiview/src/lib.rs b/ankiview/src/lib.rs index 498ddba..ef228c7 100644 --- a/ankiview/src/lib.rs +++ b/ankiview/src/lib.rs @@ -3,6 +3,7 @@ pub mod application; pub mod cli; pub mod domain; pub mod infrastructure; +pub mod inka; pub mod ports; pub mod util; @@ -33,6 +34,22 @@ pub fn run(args: Args) -> Result<()> { 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), + Command::Collect { + path, + recursive, + force, + ignore_errors, + full_sync, + update_ids, + } => handle_collect_command( + path, + recursive, + force, + ignore_errors, + full_sync, + update_ids, + collection_path, + ), } } @@ -113,6 +130,80 @@ fn handle_list_command(search_query: Option<&str>, collection_path: PathBuf) -> Ok(()) } +fn handle_collect_command( + path: PathBuf, + recursive: bool, + force: bool, + ignore_errors: bool, + full_sync: bool, + update_ids: bool, + collection_path: PathBuf, +) -> Result<()> { + use crate::inka::application::card_collector::CardCollector; + + info!( + ?path, + recursive, force, ignore_errors, full_sync, update_ids, "Collecting markdown cards" + ); + + // Initialize collector with force, full_sync, update_ids, and ignore_errors flags + let mut collector = CardCollector::new( + &collection_path, + force, + full_sync, + update_ids, + ignore_errors, + )?; + + // Process based on path type + let total_cards = if path.is_file() { + // Single file + collector.process_file(&path)? + } else if path.is_dir() { + if recursive { + // Recursive directory processing + collector.process_directory(&path)? + } else { + // Non-recursive - only process .md files in the directory + let mut count = 0; + for entry in std::fs::read_dir(&path)? { + let entry = entry?; + let entry_path = entry.path(); + if entry_path.is_file() + && entry_path.extension().and_then(|s| s.to_str()) == Some("md") + { + count += collector.process_file(&entry_path)?; + } + } + count + } + } else { + return Err(anyhow::anyhow!("Path does not exist: {:?}", path)); + }; + + // Print summary + println!( + "Successfully processed {} card{}", + total_cards, + if total_cards == 1 { "" } else { "s" } + ); + + // Print error summary if there were any errors + let errors = collector.errors(); + if !errors.is_empty() { + eprintln!( + "\n{} error{} occurred:", + errors.len(), + if errors.len() == 1 { "" } else { "s" } + ); + for error in errors { + eprintln!(" {}", error); + } + } + + 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/tests/fixtures/README.md b/ankiview/tests/fixtures/README.md index 832f3e0..85fc4e4 100644 --- a/ankiview/tests/fixtures/README.md +++ b/ankiview/tests/fixtures/README.md @@ -3,7 +3,7 @@ ## Golden Test Dataset **Source**: `/Users/Q187392/dev/s/private/ankiview/data/testuser/` -**Fixture Location**: `test_collection/` +**Fixture Location**: `test_collection/User 1/` **IMPORTANT**: The golden dataset in the source location is READ-ONLY. Never modify it. All tests work with copies. diff --git a/ankiview/tests/fixtures/build_test_collection.rs b/ankiview/tests/fixtures/build_test_collection.rs index e654ed4..938a2b6 100644 --- a/ankiview/tests/fixtures/build_test_collection.rs +++ b/ankiview/tests/fixtures/build_test_collection.rs @@ -112,12 +112,12 @@ fn create_test_media(media_dir: &std::path::Path) -> anyhow::Result<()> { ]; let rust_logo_path = media_dir.join("rust-logo.png"); - std::fs::write(&rust_logo_path, &rust_logo_png)?; + std::fs::write(&rust_logo_path, rust_logo_png)?; println!("Created test image: {:?}", rust_logo_path); // Create another simple PNG (sample.jpg - actually a PNG despite the name) let sample_path = media_dir.join("sample.jpg"); - std::fs::write(&sample_path, &rust_logo_png)?; + std::fs::write(&sample_path, rust_logo_png)?; println!("Created test image: {:?}", sample_path); Ok(()) diff --git a/ankiview/tests/fixtures/copy_golden_dataset.sh b/ankiview/tests/fixtures/copy_golden_dataset.sh index efc4b56..2a5413e 100755 --- a/ankiview/tests/fixtures/copy_golden_dataset.sh +++ b/ankiview/tests/fixtures/copy_golden_dataset.sh @@ -5,7 +5,7 @@ set -euo pipefail GOLDEN_SOURCE="/Users/Q187392/dev/s/private/ankiview/data/testuser" -FIXTURE_TARGET="ankiview/tests/fixtures/test_collection" +FIXTURE_TARGET="ankiview/tests/fixtures/test_collection/User 1" echo "Copying golden dataset to test fixtures..." diff --git a/ankiview/tests/fixtures/gh_activity.png b/ankiview/tests/fixtures/gh_activity.png new file mode 100644 index 0000000..f24ea2b Binary files /dev/null and b/ankiview/tests/fixtures/gh_activity.png differ diff --git a/ankiview/tests/fixtures/munggoggo.png b/ankiview/tests/fixtures/munggoggo.png new file mode 100644 index 0000000..2a71ac8 Binary files /dev/null and b/ankiview/tests/fixtures/munggoggo.png differ diff --git a/ankiview/tests/fixtures/test_collection/collection.anki2 b/ankiview/tests/fixtures/test_collection/User 1/collection.anki2 similarity index 100% rename from ankiview/tests/fixtures/test_collection/collection.anki2 rename to ankiview/tests/fixtures/test_collection/User 1/collection.anki2 diff --git a/ankiview/tests/fixtures/test_collection/collection.anki2-shm b/ankiview/tests/fixtures/test_collection/User 1/collection.anki2-shm similarity index 100% rename from ankiview/tests/fixtures/test_collection/collection.anki2-shm rename to ankiview/tests/fixtures/test_collection/User 1/collection.anki2-shm diff --git a/ankiview/tests/fixtures/test_collection/collection.media.db2 b/ankiview/tests/fixtures/test_collection/User 1/collection.media.db2 similarity index 100% rename from ankiview/tests/fixtures/test_collection/collection.media.db2 rename to ankiview/tests/fixtures/test_collection/User 1/collection.media.db2 diff --git a/ankiview/tests/fixtures/test_collection/collection.media/dag.png b/ankiview/tests/fixtures/test_collection/User 1/collection.media/dag.png similarity index 100% rename from ankiview/tests/fixtures/test_collection/collection.media/dag.png rename to ankiview/tests/fixtures/test_collection/User 1/collection.media/dag.png diff --git a/ankiview/tests/fixtures/test_collection/collection.media/mercator.png b/ankiview/tests/fixtures/test_collection/User 1/collection.media/mercator.png similarity index 100% rename from ankiview/tests/fixtures/test_collection/collection.media/mercator.png rename to ankiview/tests/fixtures/test_collection/User 1/collection.media/mercator.png diff --git a/ankiview/tests/fixtures/test_collection/collection.media/star-schema.png b/ankiview/tests/fixtures/test_collection/User 1/collection.media/star-schema.png similarity index 100% rename from ankiview/tests/fixtures/test_collection/collection.media/star-schema.png rename to ankiview/tests/fixtures/test_collection/User 1/collection.media/star-schema.png diff --git a/ankiview/tests/fixtures/test_collection/collection.media/wsg-enu2.png b/ankiview/tests/fixtures/test_collection/User 1/collection.media/wsg-enu2.png similarity index 100% rename from ankiview/tests/fixtures/test_collection/collection.media/wsg-enu2.png rename to ankiview/tests/fixtures/test_collection/User 1/collection.media/wsg-enu2.png diff --git a/ankiview/tests/fixtures/test_collection/prefs21.db b/ankiview/tests/fixtures/test_collection/prefs21.db new file mode 100644 index 0000000..f933073 Binary files /dev/null and b/ankiview/tests/fixtures/test_collection/prefs21.db differ diff --git a/ankiview/tests/helpers/mod.rs b/ankiview/tests/helpers/mod.rs index 7d087dd..69cd334 100644 --- a/ankiview/tests/helpers/mod.rs +++ b/ankiview/tests/helpers/mod.rs @@ -43,10 +43,11 @@ impl TestCollection { /// Get path to the fixture collection fn fixture_collection_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/test_collection/collection.anki2") + .join("tests/fixtures/test_collection/User 1/collection.anki2") } /// Open repository for this test collection + #[allow(dead_code)] pub fn open_repository(&self) -> Result { AnkiRepository::new(&self.collection_path) } diff --git a/ankiview/tests/test_anki.rs b/ankiview/tests/test_anki.rs index 606e89f..d922ca2 100644 --- a/ankiview/tests/test_anki.rs +++ b/ankiview/tests/test_anki.rs @@ -169,7 +169,7 @@ fn given_collection_when_listing_with_search_then_returns_filtered_notes() -> Re let notes = repo.list_notes(Some("Tree"))?; // Assert - assert!(notes.len() > 0); + assert!(!notes.is_empty()); assert!(notes.iter().any(|n| n.front.contains("Tree"))); Ok(()) } diff --git a/ankiview/tests/test_cli.rs b/ankiview/tests/test_cli.rs index 90ecdc9..019d49f 100644 --- a/ankiview/tests/test_cli.rs +++ b/ankiview/tests/test_cli.rs @@ -23,7 +23,7 @@ fn given_explicit_view_command_when_parsing_then_succeeds() { match parsed.command { Command::View { note_id, json } => { assert_eq!(note_id, 1234567890); - assert_eq!(json, false); + assert!(!json); } _ => panic!("Expected View command"), } @@ -90,7 +90,7 @@ fn given_global_profile_flag_when_parsing_then_succeeds() { match parsed.command { Command::View { note_id, json } => { assert_eq!(note_id, 1234567890); - assert_eq!(json, false); + assert!(!json); } _ => panic!("Expected View command"), } @@ -149,7 +149,7 @@ fn given_json_flag_when_parsing_view_command_then_json_is_true() { match parsed.command { Command::View { note_id, json } => { assert_eq!(note_id, 1234567890); - assert_eq!(json, true); + assert!(json); } _ => panic!("Expected View command"), } @@ -167,7 +167,7 @@ fn given_no_json_flag_when_parsing_view_command_then_json_is_false() { match parsed.command { Command::View { note_id, json } => { assert_eq!(note_id, 1234567890); - assert_eq!(json, false); + assert!(!json); } _ => panic!("Expected View command"), } @@ -185,7 +185,7 @@ fn given_json_flag_with_global_flags_when_parsing_then_succeeds() { match parsed.command { Command::View { note_id, json } => { assert_eq!(note_id, 1234567890); - assert_eq!(json, true); + assert!(json); } _ => panic!("Expected View command"), } diff --git a/ankiview/tests/test_collect.rs b/ankiview/tests/test_collect.rs new file mode 100644 index 0000000..734081f --- /dev/null +++ b/ankiview/tests/test_collect.rs @@ -0,0 +1,240 @@ +mod helpers; + +use anyhow::Result; +use helpers::TestCollection; +use std::fs; +use tempfile::TempDir; + +#[test] +fn given_markdown_file_when_collecting_then_creates_notes_in_anki() -> Result<()> { + // Arrange + let test_collection = TestCollection::new()?; + let temp_dir = TempDir::new()?; + + // Create a markdown file with basic cards only (simpler test) + let markdown_path = temp_dir.path().join("test.md"); + let markdown_content = r#"--- +Deck: IntegrationTest + +1. What is the capital of France? +> Paris + +2. What is 2 + 2? +> 4 +---"#; + fs::write(&markdown_path, markdown_content)?; + + // Act + let mut collector = ankiview::inka::application::card_collector::CardCollector::new( + &test_collection.collection_path, + false, + false, + false, + false, + )?; + let count = collector.process_file(&markdown_path)?; + + // Assert + assert_eq!(count, 2, "Should process 2 cards"); + + // Verify IDs were injected + let updated_content = fs::read_to_string(&markdown_path)?; + assert!( + updated_content.contains("") + .next()? + .trim() + .parse::() + .ok() + }) + .collect(); + + assert_eq!(ids.len(), 3, "Should extract 3 valid IDs"); + + // Verify IDs are non-zero and unique + for id in &ids { + assert!(*id > 0, "ID should be positive"); + } + + let unique_ids: std::collections::HashSet<_> = ids.iter().collect(); + assert_eq!(unique_ids.len(), 3, "All IDs should be unique"); + + Ok(()) +}