├── .github └── workflows │ ├── release_old.yml │ └── release_wheels.yml ├── .gitignore ├── .idea ├── .gitignore ├── aws.xml ├── bkmr.iml ├── dataSources.xml ├── google-java-format.xml ├── inspectionProfiles │ └── Project_Default.xml ├── jpa-buddy.xml ├── misc.xml ├── modules.xml ├── runConfigurations │ ├── All Tests.run.xml │ ├── Test main__tests.run.xml │ ├── Test tag.run.xml │ ├── Test tag__test__test_clean_tags.run.xml │ ├── Test tag__test__test_create_normalized_tag_string.run.xml │ ├── Test tag__test__test_tags.run.xml │ ├── Test test_bms.run.xml │ ├── Test test_check_tags.run.xml │ ├── Test test_create_db.run.xml │ ├── Test test_dal__test_bm_exists.run.xml │ ├── Test test_dal__test_get_bookmarks.run.xml │ ├── Test test_embeddings.run.xml │ ├── Test test_update_bm.run.xml │ ├── Test_environment.xml │ ├── Test_test_dal.xml │ ├── Test_test_process.xml │ ├── Tests in 'tests'.run.xml │ ├── _template__of_Cargo.xml │ ├── rsenv.sh │ ├── test_abspath.run.xml │ ├── test_clean_tags.run.xml │ ├── test_create_normalized_tag_string.run.xml │ ├── test_delete_bms.run.xml │ ├── test_do_sth_with_bms.run.xml │ ├── test_ensure_int_vector.run.xml │ ├── test_environment.run.xml │ ├── test_get_bookmarks.run.xml │ ├── test_init_db.run.xml │ ├── test_lib.run.xml │ ├── test_open_bm.run.xml │ ├── test_open_bms.run.xml │ ├── test_parse_tags.run.xml │ ├── test_print_ids.run.xml │ ├── test_process.run.xml │ ├── test_show_bms.run.xml │ ├── twbm add.run.xml │ ├── twbm delete.run.xml │ ├── twbm edit.run.xml │ ├── twbm search.run.xml │ ├── twbm show.run.xml │ └── twbm update.run.xml ├── sqldialects.xml └── vcs.xml ├── ARCHITECTURE.md ├── AUTHORS ├── CONTRIBUTING.md ├── ERROR_HANDLING.md ├── LICENSE ├── Makefile ├── README.md ├── TODO.md ├── VERSION ├── analysis.md ├── bkmr ├── Cargo.lock ├── Cargo.toml ├── diesel.toml ├── migrations │ ├── .keep │ ├── 2022-12-29-110455_create_bookmarks │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-12-17-200409_add_embedding_and_content_hash_to_bookmarks │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-03-23-132320_add_created_at │ │ ├── down.sql │ │ └── up.sql │ └── README.md ├── src │ ├── app_state.rs │ ├── application │ │ ├── actions │ │ │ ├── default_action.rs │ │ │ ├── env_action.rs │ │ │ ├── markdown_action.rs │ │ │ ├── mod.rs │ │ │ ├── shell_action.rs │ │ │ ├── snippet_action.rs │ │ │ ├── text_action.rs │ │ │ └── uri_action.rs │ │ ├── error.rs │ │ ├── mod.rs │ │ ├── services │ │ │ ├── action_service.rs │ │ │ ├── bookmark_service.rs │ │ │ ├── bookmark_service_impl.rs │ │ │ ├── factory.rs │ │ │ ├── interpolation.rs │ │ │ ├── mod.rs │ │ │ ├── tag_service.rs │ │ │ ├── tag_service_impl.rs │ │ │ └── template_service.rs │ │ └── templates │ │ │ ├── bookmark_template.rs │ │ │ └── mod.rs │ ├── cli │ │ ├── args.rs │ │ ├── bookmark_commands.rs │ │ ├── completion.rs │ │ ├── display.rs │ │ ├── error.rs │ │ ├── fzf.rs │ │ ├── mod.rs │ │ ├── process.rs │ │ └── tag_commands.rs │ ├── config.rs │ ├── default_config.toml │ ├── domain │ │ ├── action.rs │ │ ├── action_resolver.rs │ │ ├── bookmark.rs │ │ ├── embedding.rs │ │ ├── error.rs │ │ ├── interpolation │ │ │ ├── errors.rs │ │ │ ├── interface.rs │ │ │ └── mod.rs │ │ ├── mod.rs │ │ ├── repositories │ │ │ ├── import_repository.rs │ │ │ ├── mod.rs │ │ │ ├── query.rs │ │ │ └── repository.rs │ │ ├── search.rs │ │ ├── services │ │ │ ├── clipboard.rs │ │ │ └── mod.rs │ │ ├── system_tag.rs │ │ └── tag.rs │ ├── infrastructure │ │ ├── clipboard.rs │ │ ├── embeddings │ │ │ ├── dummy_provider.rs │ │ │ ├── mod.rs │ │ │ ├── model.rs │ │ │ └── openai_provider.rs │ │ ├── error.rs │ │ ├── http.rs │ │ ├── interpolation │ │ │ ├── minijinja_engine.rs │ │ │ └── mod.rs │ │ ├── json.rs │ │ ├── mod.rs │ │ └── repositories │ │ │ ├── json_import_repository.rs │ │ │ ├── mod.rs │ │ │ └── sqlite │ │ │ ├── connection.rs │ │ │ ├── error.rs │ │ │ ├── migration.rs │ │ │ ├── mod.rs │ │ │ ├── model.rs │ │ │ ├── repository.rs │ │ │ └── schema.rs │ ├── lib.rs │ ├── main.rs │ └── util │ │ ├── helper.rs │ │ ├── mod.rs │ │ ├── path.rs │ │ └── testing.rs └── tests │ ├── application │ ├── mod.rs │ └── services │ │ ├── mod.rs │ │ ├── test_bookmark_service_impl_load_json_bookmarks.rs │ │ └── test_bookmark_service_impl_search.rs │ ├── cli │ ├── mod.rs │ ├── test_bookmark_commands.rs │ └── test_search.rs │ ├── infrastructure │ ├── interpolation │ │ ├── minijinja_engine_test.rs │ │ └── mod.rs │ └── mod.rs │ ├── resources │ ├── bkmr.pptx │ ├── bkmr.v1.db │ ├── bkmr.v2.db │ ├── bkmr.v2.noembed.db │ ├── bookmarks.ndjson │ ├── data.ndjson │ ├── invalid_data.ndjson │ ├── sample_docu.md │ └── snips.json │ ├── test_lib.rs │ └── test_main.rs ├── brew └── release.yml ├── db ├── bkmr.db.bkp └── queries.sql ├── docs ├── advanced_usage.md ├── asciinema │ ├── README.md │ ├── bkmr4-all.cast │ ├── demo-env.sh │ ├── demo10_env.sh │ ├── demo11_all.sh │ ├── demo1_setup.sh │ ├── demo2_search_filter.sh │ ├── demo3_edit_update.sh │ ├── demo4_tag_mgmt.sh │ ├── demo5_interactive_fzf.sh │ ├── demo6_snips.sh │ ├── demo7_import_export.sh │ ├── demo8_surprise.sh │ └── demo9_semantic_search.sh ├── bkmr.pptx ├── bkmr4-bookmarks.png ├── bkmr4-fzf-snippets.png ├── configuration.md ├── content-types.md ├── semantic-search.md ├── smart-actions.md └── template-interpolation.md ├── pyproject.toml ├── resources ├── documentation.pptx ├── sem_search.png └── sem_search_vs_fts.png ├── scratch ├── asciinema_script.txt ├── bkmr.cast ├── create.rs ├── delete.rs ├── migrate.rs ├── read.rs └── update.rs ├── scripts └── openapi_embed.py ├── sql ├── check_embed.sql ├── check_schema.sh ├── compact.sql ├── experiments.sql └── find_nulls.sql └── uv.lock /.github/workflows/release_old.yml: -------------------------------------------------------------------------------- 1 | name: Release crate 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | publish_lib: 7 | runs-on: ubuntu-22.04 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v3 11 | with: 12 | fetch-depth: 0 13 | 14 | - name: Use cached dependencies 15 | uses: Swatinem/rust-cache@v2 16 | 17 | - name: Prepare 18 | run: cargo install cargo-release 19 | 20 | - name: Login to Crates.io 21 | run: cargo login ${{ secrets.CRATESIO_TOKEN }} 22 | 23 | - name: Publish lib 24 | working-directory: ./bkmr 25 | run: cargo release publish --no-confirm 26 | -------------------------------------------------------------------------------- /.github/workflows/release_wheels.yml: -------------------------------------------------------------------------------- 1 | # This file is autogenerated by maturin v1.8.3 2 | # To update, run 3 | # 4 | # maturin generate-ci github --platform macos --platform linux -m bkmr/Cargo.toml 5 | # 6 | name: Build and Publish Wheels 7 | 8 | on: 9 | push: 10 | tags: 11 | - 'v[0-9]+.*' # Match tags that follow the versioning pattern like v1.0.0, v2.1.3, etc. 12 | workflow_dispatch: 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | linux: 19 | runs-on: ${{ matrix.platform.runner }} 20 | strategy: 21 | matrix: 22 | platform: 23 | - runner: ubuntu-22.04 24 | target: x86_64 25 | python-version: ['3.10', '3.11', '3.12', '3.13'] 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Display directory tree (3 levels) 29 | run: | 30 | pwd 31 | tree -L 3 32 | - uses: actions/setup-python@v5 33 | with: 34 | python-version: 3.x 35 | - name: Build wheels 36 | uses: PyO3/maturin-action@v1 37 | with: 38 | target: ${{ matrix.platform.target }} 39 | args: --release --out dist --interpreter python${{ matrix.python-version }} --manifest-path bkmr/Cargo.toml 40 | sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} 41 | manylinux: auto 42 | # Install OpenSSL development libraries in the Manylinux container 43 | before-script-linux: | 44 | yum install -y openssl-devel 45 | - name: Upload wheels 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: wheels-linux-${{ matrix.platform.target }}-py${{ matrix.python-version }}-${{ github.run_id }} 49 | path: dist 50 | 51 | macos: 52 | runs-on: ${{ matrix.platform.runner }} 53 | strategy: 54 | matrix: 55 | platform: 56 | - runner: macos-13 57 | target: x86_64 58 | - runner: macos-14 59 | target: aarch64 60 | python-version: ['3.10', '3.11', '3.12', '3.13'] 61 | steps: 62 | - uses: actions/checkout@v4 63 | - uses: actions/setup-python@v5 64 | with: 65 | python-version: 3.x 66 | - name: Build wheels 67 | uses: PyO3/maturin-action@v1 68 | with: 69 | target: ${{ matrix.platform.target }} 70 | args: --release --out dist --interpreter python${{ matrix.python-version }} --manifest-path bkmr/Cargo.toml 71 | sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} 72 | - name: Upload wheels 73 | uses: actions/upload-artifact@v4 74 | with: 75 | name: wheels-macos-${{ matrix.platform.target }}-py${{ matrix.python-version }}-${{ github.run_id }} 76 | path: dist 77 | 78 | sdist: 79 | runs-on: ubuntu-latest 80 | steps: 81 | - uses: actions/checkout@v4 82 | - name: Build sdist 83 | uses: PyO3/maturin-action@v1 84 | with: 85 | command: sdist 86 | args: --out dist --manifest-path bkmr/Cargo.toml 87 | - name: Upload sdist 88 | uses: actions/upload-artifact@v4 89 | with: 90 | name: wheels-sdist-${{ github.run_id }} 91 | path: dist 92 | 93 | release: 94 | name: Release 95 | runs-on: ubuntu-latest 96 | if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} 97 | needs: [linux, macos, sdist] 98 | permissions: 99 | # Use to sign the release artifacts 100 | id-token: write 101 | # Used to upload release artifacts 102 | contents: write 103 | # Used to generate artifact attestation 104 | attestations: write 105 | steps: 106 | - uses: actions/download-artifact@v4 107 | with: 108 | pattern: wheels-*-${{ github.run_id }} 109 | merge-multiple: true 110 | path: dist 111 | - name: Generate artifact attestation 112 | uses: actions/attest-build-provenance@v2 113 | with: 114 | subject-path: 'dist/*' 115 | - name: Display structure of downloaded files 116 | run: ls -R . 117 | - name: Publish to PyPI 118 | # if: ${{ startsWith(github.ref, 'refs/tags/') }} 119 | uses: PyO3/maturin-action@v1 120 | env: 121 | MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} 122 | with: 123 | command: upload 124 | args: --non-interactive --skip-existing dist/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/workspace.xml 2 | db/bkmr_backup_*.db 3 | db/bkmr.*.noembed.db 4 | db/bkmr.db 5 | .envrc 6 | # Created by https://www.toptal.com/developers/gitignore/api/rust 7 | # Edit at https://www.toptal.com/developers/gitignore?templates=rust 8 | 9 | ### Rust ### 10 | # Generated by Cargo 11 | # will have compiled files and executables 12 | debug/ 13 | target/ 14 | 15 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 16 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 17 | #Cargo.lock 18 | 19 | # These are backup files generated by rustfmt 20 | **/*.rs.bk 21 | 22 | # MSVC Windows builds of rustc generate these, which store debugging information 23 | *.pdb 24 | 25 | # End of https://www.toptal.com/developers/gitignore/api/rust 26 | db/bkmr.v1.db 27 | db/bkmr.v2.db 28 | ~$documentation.pptx 29 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | # GitHub Copilot persisted chat sessions 10 | /copilot/chatSessions 11 | -------------------------------------------------------------------------------- /.idea/aws.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/bkmr.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/google-java-format.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 57 | -------------------------------------------------------------------------------- /.idea/jpa-buddy.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/runConfigurations/All Tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Test main__tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Test tag.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Test tag__test__test_clean_tags.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Test tag__test__test_create_normalized_tag_string.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Test tag__test__test_tags.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Test test_bms.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Test test_check_tags.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Test test_create_db.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Test test_dal__test_bm_exists.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Test test_dal__test_get_bookmarks.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Test test_embeddings.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Test test_update_bm.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Test_environment.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Test_test_dal.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Test_test_process.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Tests in 'tests'.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/_template__of_Cargo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/rsenv.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # DO NOT DELETE 4 | # 5 | [[ -f "$SOPS_PATH/environments/${RUN_ENV:-local}.env" ]] && rsenv build "$SOPS_PATH/environments/${RUN_ENV:-local}.env" 6 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_abspath.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_clean_tags.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | 42 | 43 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_create_normalized_tag_string.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_delete_bms.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_do_sth_with_bms.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_ensure_int_vector.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_environment.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_get_bookmarks.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_init_db.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_lib.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_open_bm.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_open_bms.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_parse_tags.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_print_ids.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_process.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/test_show_bms.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/twbm add.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/twbm delete.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/twbm edit.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/twbm search.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/twbm show.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/runConfigurations/twbm update.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/sqldialects.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Architectural Guidelines: Use of `Arc` Pattern 2 | 3 | ## Core Principle 4 | 5 | In the BKMR codebase, we follow a consistent pattern of wrapping service and repository implementations in `Arc` to enable flexible dependency injection while maintaining thread-safe shared ownership. 6 | 7 | ## Pattern Definition 8 | 9 | 1. **Services**: All service implementations (e.g., `BookmarkServiceImpl`, `ActionServiceImpl`) are: 10 | - Created by factory functions that return `Arc` 11 | - Never exposed as concrete types to consumers 12 | - Always passed around as `Arc` 13 | 14 | 2. **Repositories**: All repository implementations (e.g., `SqliteBookmarkRepository`) are: 15 | - Created once and wrapped in `Arc` 16 | - Shared across multiple services 17 | - Accessed via reference-counted pointers 18 | 19 | 3. **Cross-Layer Dependencies**: When services depend on other services or repositories: 20 | - They always store an `Arc` field 21 | - They accept `Arc` in constructors 22 | - They never unwrap or store concrete types 23 | 24 | ## Architectural Consequences 25 | 26 | 1. **Dependency Injection**: This pattern enables a simple form of dependency injection where: 27 | - Factory functions create and wire together components 28 | - The `AppState` singleton provides global access to core services 29 | - Testing can substitute mock implementations via trait objects 30 | 31 | 2. **Thread Safety**: All services and repositories can be safely shared across threads because: 32 | - `Arc` provides thread-safe reference counting 33 | - Trait objects hide implementation details 34 | - Internal mutability is handled via repository implementations 35 | 36 | 3. **Service Lifetime**: Services and repositories exist for the application's entire lifetime: 37 | - They are created once at startup 38 | - They may be cloned but never dropped until shutdown 39 | - No ownership conflicts occur due to reference counting 40 | 41 | 4. **Polymorphism**: The system can swap implementations without changing consumer code: 42 | - Different repository backends (SQLite, in-memory, etc.) 43 | - Alternative service implementations for different contexts 44 | - Mocks for testing 45 | 46 | ## Implementation Rules 47 | 48 | 1. **Factory Functions**: 49 | ```rust 50 | // Always return Arc, never concrete types 51 | pub fn create_bookmark_service() -> Arc { 52 | // Implementation details here 53 | Arc::new(BookmarkServiceImpl::new(/* dependencies */)) 54 | } 55 | ``` 56 | 57 | 2. **Service Constructors**: 58 | ```rust 59 | pub struct BookmarkServiceImpl { 60 | // Always store dependencies as Arc 61 | repository: Arc, 62 | embedder: Arc, 63 | import_repository: Arc, 64 | } 65 | 66 | impl BookmarkServiceImpl { 67 | // Always accept Arc for dependencies 68 | pub fn new( 69 | repository: Arc, 70 | embedder: Arc, 71 | import_repository: Arc, 72 | ) -> Self { 73 | Self { repository, embedder, import_repository } 74 | } 75 | } 76 | ``` 77 | 78 | 3. **Consumers**: 79 | ```rust 80 | // Accept services as Arc 81 | fn process_bookmarks(service: Arc) { 82 | // Use service methods directly 83 | let bookmarks = service.get_all_bookmarks(None, None)?; 84 | 85 | // Can clone for async operations or parallel processing 86 | let service_clone = service.clone(); 87 | tokio::spawn(async move { 88 | service_clone.process_async().await; 89 | }); 90 | } 91 | ``` 92 | 93 | ## When to Use Other Patterns 94 | 95 | 1. **Non-Shared Components**: For components used only in a single context and not shared: 96 | - Consider `Box` if dynamic dispatch is needed but not sharing 97 | - Consider concrete types if polymorphism isn't required 98 | 99 | 2. **Function-Local Dependencies**: For dependencies used only within a single function: 100 | - Consider passing by reference (`&dyn Trait`) instead of Arc 101 | - Use concrete types when possible 102 | 103 | 3. **Short-lived Objects**: For objects with clear lifetimes: 104 | - Domain entities generally shouldn't use Arc 105 | - Values returned from service methods may use simpler ownership patterns 106 | 107 | ## Conclusion 108 | 109 | The consistent use of `Arc` throughout the service and repository layers provides a predictable, 110 | flexible architecture that supports dependency injection, polymorphism, and thread safety. 111 | 112 | This pattern should be followed for all new services and repositories to maintain architectural consistency. -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | sysid 2 | Peter Sonntag 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to BKMR 2 | 3 | Thank you for considering contributing to BKMR! This document outlines the process for contributing to the project and provides guidelines to ensure consistent code quality. 4 | 5 | ## Code of Conduct 6 | 7 | Please be respectful to all contributors and users. We aim to foster an inclusive and welcoming community. 8 | 9 | ## Getting Started 10 | 11 | ### Prerequisites 12 | 13 | - Rust (stable channel) 14 | - Cargo 15 | - SQLite development libraries 16 | 17 | ### Setting Up Development Environment 18 | 19 | 1. Clone the repository: 20 | ```bash 21 | git clone https://github.com/yourusername/bkmr.git 22 | cd bkmr 23 | ``` 24 | 25 | 2. Build the project: 26 | ```bash 27 | cargo build 28 | ``` 29 | 30 | 3. Run tests: 31 | ```bash 32 | cargo test 33 | ``` 34 | 35 | ## Development Workflow 36 | 37 | 1. Create a new branch for your feature or fix: 38 | ```bash 39 | git checkout -b feature/your-feature-name 40 | ``` 41 | 42 | 2. Make your changes following the code style guidelines below. 43 | 44 | 3. Write tests for your changes. 45 | 46 | 4. Run tests locally: 47 | ```bash 48 | cargo test 49 | ``` 50 | 51 | 5. Submit a pull request. 52 | 53 | ## Code Style Guidelines 54 | 55 | ### Architecture 56 | 57 | The project follows clean architecture principles with an onion model: 58 | 59 | - `domain`: Core business logic and entities 60 | - `application`: Use cases and services 61 | - `infrastructure`: External systems integration 62 | - `cli`: Command-line interface 63 | 64 | ### Naming Conventions 65 | 66 | - Use descriptive names that convey intent 67 | - For test functions, follow the pattern: `given_X_when_Y_then_Z()` 68 | - Use snake_case for functions, variables, and file names 69 | - Use PascalCase for structs, enums, and traits 70 | 71 | ### Error Handling 72 | 73 | - Use `thiserror` for error definitions 74 | - Provide meaningful error messages 75 | 76 | ### Testing 77 | 78 | - Write unit tests for all public functions 79 | - Follow the Arrange/Act/Assert pattern 80 | 81 | Example test structure: 82 | ```rust 83 | #[test] 84 | fn given_valid_input_when_parsing_then_returns_expected_result() { 85 | // Arrange 86 | let input = "valid input"; 87 | 88 | // Act 89 | let result = parse_input(input); 90 | 91 | // Assert 92 | assert!(result.is_ok()); 93 | assert_eq!(result.unwrap(), expected_value); 94 | } 95 | ``` 96 | 97 | ### Documentation 98 | 99 | - Document all public APIs 100 | - Include examples in documentation where appropriate 101 | - Keep README up to date 102 | 103 | ## Pull Request Process 104 | 105 | 1. Ensure all tests pass 106 | 2. Update documentation if necessary 107 | 3. Add a clear description of the changes 108 | 4. Link related issues 109 | 110 | ## Release Process 111 | 112 | 1. Version numbers follow [Semantic Versioning](https://semver.org/) 113 | 2. Releases are created from the `main` branch 114 | 3. Each release includes a changelog entry 115 | 116 | ## Finding Tasks to Work On 117 | 118 | - Check the issues tab for tasks labeled "good first issue" 119 | - Feel free to ask for clarification or help on any issue 120 | 121 | Thank you for contributing to BKMR! 122 | -------------------------------------------------------------------------------- /ERROR_HANDLING.md: -------------------------------------------------------------------------------- 1 | # Error Handling Guidelines for BKMR Project 2 | 3 | ## Error Type Hierarchy 4 | 5 | ``` 6 | Infrastructure → Domain → Application → Presentation (CLI) 7 | ``` 8 | 9 | ### Key Error Types 10 | 11 | 1. **Infrastructure Layer** 12 | - `SqliteRepositoryError`: Database-specific errors 13 | - `InfrastructureError`: General infrastructure failures 14 | - `InterpolationError`: Template rendering failures 15 | 16 | 2. **Domain Layer** 17 | - `DomainError`: Core business logic errors 18 | - `RepositoryError`: Abstract repository interface errors 19 | 20 | 3. **Application Layer** 21 | - `ApplicationError`: Service-level errors 22 | 23 | 4. **Presentation Layer** 24 | - `CliError`: Command-line interface errors 25 | 26 | ## Core Error Handling Principles 27 | 28 | 1. **Error Context Enhancement** 29 | - All error types implement a `context()` method for adding context 30 | - Context should clarify where and why the error occurred 31 | 32 | ```rust 33 | // Example: Adding context to an error 34 | repository.get_by_id(id) 35 | .map_err(|e| e.context(format!("Failed to retrieve bookmark {}", id)))? 36 | ``` 37 | 38 | 2. **Explicit Error Conversion** 39 | - Use `From` traits to convert between error types at layer boundaries 40 | - More specific conversions for important cases, fallback for others 41 | 42 | ```rust 43 | // Example: Converting SqliteRepositoryError to DomainError 44 | impl From for DomainError { 45 | fn from(err: SqliteRepositoryError) -> Self { 46 | match err { 47 | SqliteRepositoryError::BookmarkNotFound(id) => 48 | DomainError::BookmarkNotFound(id.to_string()), 49 | SqliteRepositoryError::DatabaseError(e) => 50 | DomainError::RepositoryError(RepositoryError::Database(e.to_string())), 51 | // Other specific mappings... 52 | _ => DomainError::RepositoryError(RepositoryError::Other(err.to_string())), 53 | } 54 | } 55 | } 56 | ``` 57 | 58 | 3. **Error Propagation with `?` Operator** 59 | - Use the `?` operator for clean error propagation 60 | - Convert error types at layer boundaries with `.map_err()` 61 | 62 | ```rust 63 | fn add_bookmark(&self, ...) -> ApplicationResult { 64 | // Convert domain errors to application errors implicitly with ? 65 | let bookmark = self.repository.add(&mut bookmark)?; 66 | 67 | // Or explicitly with map_err 68 | self.repository.add(&mut bookmark) 69 | .map_err(|e| ApplicationError::Domain(e))?; 70 | } 71 | ``` 72 | 73 | 4. **Error Type Patterns** 74 | - Use `thiserror` for defining structured error enums 75 | - Include helpful error messages in the `#[error("...")]` attributes 76 | - Provide serialized field values in error messages 77 | 78 | ```rust 79 | #[derive(Error, Debug)] 80 | pub enum DomainError { 81 | #[error("Bookmark not found: {0}")] 82 | BookmarkNotFound(String), 83 | 84 | #[error("Repository error: {0}")] 85 | RepositoryError(#[from] RepositoryError), 86 | 87 | // Other error variants... 88 | } 89 | ``` 90 | 91 | 5. **Result Type Aliases** 92 | - Define `Result` type aliases for each layer 93 | 94 | ```rust 95 | pub type DomainResult = Result; 96 | pub type ApplicationResult = Result; 97 | pub type CliResult = Result; 98 | pub type SqliteResult = Result; 99 | ``` 100 | 101 | ## Error Presentation 102 | 103 | - CLI errors should be user-friendly with actionable information 104 | - Use color in terminal output (via `crossterm` crate) to highlight errors 105 | - Include suggestions for resolution when possible 106 | 107 | ```rust 108 | eprintln!("{}", "Error: Database not found.".red()); 109 | eprintln!("Either:"); 110 | eprintln!(" 1. Set BKMR_DB_URL environment variable to point to an existing database"); 111 | eprintln!(" 2. Create a database using 'bkmr create-db '"); 112 | ``` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2023, [sysid](https://sysid.github.io/). 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # LLM todo 2 | 3 | 2-stage search: use rsnip algo 4 | autocompletion like rsnip for snippet titles and types 5 | 6 | test coverage and test cases (integration) 7 | 8 | From trait for errors, simplify/optimize error handling 9 | 10 | embedding sqlite plugin 11 | 12 | ## Chore 13 | - check whether arboard can be replaced with crossterm CopyToClipboard 14 | 15 | ## Features 16 | 17 | ### Proposals 18 | - SOPS integration 19 | - https://lib.rs/crates/inlyne 20 | 21 | ## BUGs 22 | sqlite bug: jupy* 23 | 24 | 25 | ## Gotcha 26 | - bkmr search grad* # shell expands to gradlew if present 27 | - edit with vim does not work when "sourcing" like: source <()> because of terminal 28 | - skim requires TERM: export TERM=xterm-256color 29 | 30 | 31 | ## Advanced 32 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 4.23.8 2 | -------------------------------------------------------------------------------- /bkmr/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bkmr" 3 | version = "4.23.8" 4 | edition = "2021" 5 | description = "A Unified CLI Tool for Bookmark, Snippet, and Knowledge Management" 6 | repository = "https://github.com/sysid/bkmr" 7 | readme = "../README.md" 8 | license = "BSD-3-Clause" 9 | authors = ["sysid "] 10 | homepage = "https://github.com/sysid/bkmr" 11 | keywords = ["bookmark", "cli", "terminal", "snippet", "launcher"] 12 | categories = ["command-line-utilities"] 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | arboard = "3.5.0" 18 | bincode = "2.0.1" 19 | byteorder = "1.5.0" 20 | chrono = { version = "0.4.41", features = ["serde"] } 21 | clap = { version = "4.5.37", features = ["unstable-doc"] } 22 | clap_complete = "4.5.50" 23 | crossterm = "0.29.0" 24 | derive_builder = "0.20.2" 25 | diesel = { version = "2.2.10", features = ["sqlite", "chrono", "returning_clauses_for_sqlite_3_35", "r2d2"] } 26 | diesel_migrations = "2.2.0" 27 | dirs = "6.0.0" 28 | fs_extra = "1.3.0" 29 | indoc = "2.0.6" 30 | itertools = "0.14.0" 31 | maplit = "1.0.2" 32 | markdown = "1.0.0" 33 | md5 = "0.7.0" 34 | minijinja = "2.10.2" 35 | ndarray = "0.16.1" 36 | open = "5.3.2" 37 | predicates = "3.1.3" 38 | rand = "0.9.1" 39 | regex = "1.11.1" 40 | reqwest = {version = "0.12.15", features = ["blocking", "json"] } 41 | rusqlite = { version = "0.35.0", features = ["bundled"] } # https://github.com/sysid/bkmr/issues/6#issuecomment-1435966997 42 | select = "0.6.1" 43 | serde = { version = "1.0.219", features = ["derive"] } 44 | serde_derive = "1.0.219" 45 | serde_json = "1.0.140" 46 | serde_with = {version = "3.12.0", features =["chrono"] } 47 | shellexpand = "3.1.1" 48 | skim = "0.17.2" 49 | tempfile = "3.19.1" 50 | termcolor = "1.4.1" 51 | thiserror = "2.0.12" 52 | toml = "0.8.22" 53 | tracing = "0.1.41" 54 | tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 55 | tuikit = "0.5.0" 56 | url = "2.5.4" 57 | 58 | [dev-dependencies] 59 | assert_cmd = "2.0.17" 60 | float-cmp = "0.10.0" 61 | serial_test = "3.2.0" 62 | 63 | [build-dependencies] 64 | pyo3 = { version = "0.23.5", features = ["extension-module", "anyhow"] } 65 | 66 | 67 | [profile.release] 68 | codegen-units = 1 69 | lto = true 70 | -------------------------------------------------------------------------------- /bkmr/diesel.toml: -------------------------------------------------------------------------------- 1 | # For documentation on how to configure this file, 2 | # see https://diesel.rs/guides/configuring-diesel-cli 3 | 4 | [print_schema] 5 | file = "src/adapter/dal/schema.rs" 6 | 7 | [migrations_directory] 8 | dir = "migrations" 9 | -------------------------------------------------------------------------------- /bkmr/migrations/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysid/bkmr/a1bce1bca10712aba836354f98a7cc179045f502/bkmr/migrations/.keep -------------------------------------------------------------------------------- /bkmr/migrations/2022-12-29-110455_create_bookmarks/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE bookmarks; 3 | DROP TABLE bookmarks_fts; 4 | -------------------------------------------------------------------------------- /bkmr/migrations/2022-12-29-110455_create_bookmarks/up.sql: -------------------------------------------------------------------------------- 1 | create table bookmarks 2 | ( 3 | id INTEGER not null primary key, 4 | URL VARCHAR not null unique, 5 | metadata VARCHAR not null default '', 6 | tags VARCHAR not null default '', 7 | desc VARCHAR not null default '', 8 | flags INTEGER not null default 0, 9 | last_update_ts DATETIME not null default CURRENT_TIMESTAMP 10 | ); 11 | 12 | CREATE TRIGGER [UpdateLastTime] 13 | AFTER UPDATE 14 | ON bookmarks 15 | FOR EACH ROW 16 | WHEN NEW.last_update_ts <= OLD.last_update_ts 17 | BEGIN 18 | update bookmarks set last_update_ts=CURRENT_TIMESTAMP where id = OLD.id; 19 | END; 20 | 21 | create virtual table bookmarks_fts using fts5 22 | ( 23 | id, 24 | URL, 25 | metadata, 26 | tags, 27 | "desc", 28 | flags UNINDEXED, 29 | last_update_ts UNINDEXED, 30 | content= 'bookmarks', 31 | content_rowid= 'id', 32 | tokenize= "porter unicode61" 33 | ); 34 | 35 | CREATE TRIGGER bookmarks_ad 36 | AFTER DELETE 37 | ON bookmarks 38 | BEGIN 39 | INSERT INTO bookmarks_fts (bookmarks_fts, rowid, URL, metadata, tags, "desc") 40 | VALUES ('delete', old.id, old.URL, old.metadata, old.tags, old.desc); 41 | END; 42 | 43 | CREATE TRIGGER bookmarks_ai 44 | AFTER INSERT 45 | ON bookmarks 46 | BEGIN 47 | INSERT INTO bookmarks_fts (rowid, URL, metadata, tags, "desc") 48 | VALUES (new.id, new.URL, new.metadata, new.tags, new.desc); 49 | END; 50 | 51 | CREATE TRIGGER bookmarks_au 52 | AFTER UPDATE 53 | ON bookmarks 54 | BEGIN 55 | INSERT INTO bookmarks_fts (bookmarks_fts, rowid, URL, metadata, tags, "desc") 56 | VALUES ('delete', old.id, old.URL, old.metadata, old.tags, old.desc); 57 | INSERT INTO bookmarks_fts (rowid, URL, metadata, tags, "desc") 58 | VALUES (new.id, new.URL, new.metadata, new.tags, new.desc); 59 | END; 60 | 61 | /* 62 | create table bookmarks_fts_config 63 | ( 64 | k not null primary key, 65 | v 66 | ) 67 | without rowid; 68 | 69 | create table bookmarks_fts_data 70 | ( 71 | id INTEGER primary key, 72 | block BLOB 73 | ); 74 | 75 | create table bookmarks_fts_docsize 76 | ( 77 | id INTEGER primary key, 78 | sz BLOB 79 | ); 80 | 81 | create table bookmarks_fts_idx 82 | ( 83 | segid not null, 84 | term not null, 85 | pgno, 86 | primary key (segid, term) 87 | ) 88 | without rowid; 89 | 90 | */ 91 | insert into main.bookmarks (URL, metadata, tags, "desc", flags) 92 | values 93 | ('https://www.google.com', 'Google', ',ccc,yyy,', 'Example Entry', 0), 94 | ('http://xxxxx/yyyyy', 'TEST: entry for bookmark xxxx', ',ccc,xxx,yyy,', 'nice description b', 0), 95 | ('http://aaaaa/bbbbb', 'TEST: entry for bookmark bbbb', ',aaa,bbb,', 'nice description a', 0), 96 | ('http://asdf/asdf', 'bla blub', ',aaa,bbb,', 'nice description a2', 0), 97 | ('http://asdf2/asdf2', 'bla blub2', ',aaa,bbb,ccc,', 'nice description a3', 0), 98 | ('http://11111/11111', 'bla blub3', ',aaa,bbb,ccc,', 'nice description a4', 0), 99 | ('http://none/none', '', ',,', '', 0), 100 | ('/Users/Q187392', 'home', ',,', '', 0), 101 | ('$HOME/dev', 'dev', ',,', '', 0), 102 | ('$HOME/dev/s/public/bkmr/bkmr/tests/resources/bkmr.pptx', 'pptx', ',,', '', 0), 103 | ('https://example.com/{{ env_USER }}/dashboard', 'Checking jinja', ',,', '', 0), 104 | ('text with environment varialbe default: {{ env("MY_VAR", "ENV_FALLBACK_VALUE") }}/dashboard', 'env', ',_snip_,', '', 0), 105 | ('bkmr/tests/resources/sample_docu.md', 'markdown file', ',_md_,', '', 0), 106 | ('shell::vim +/"## SqlAlchemy" $HOME/dev/s/public/bkmr/bkmr/tests/resources/sample_docu.md', 'shell open vim', ',,', '', 0) 107 | ; 108 | -------------------------------------------------------------------------------- /bkmr/migrations/2023-12-17-200409_add_embedding_and_content_hash_to_bookmarks/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE bookmarks DROP COLUMN embedding; 3 | ALTER TABLE bookmarks DROP COLUMN content_hash; 4 | -------------------------------------------------------------------------------- /bkmr/migrations/2023-12-17-200409_add_embedding_and_content_hash_to_bookmarks/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE bookmarks ADD COLUMN embedding BLOB; 3 | ALTER TABLE bookmarks ADD COLUMN content_hash BLOB; 4 | -------------------------------------------------------------------------------- /bkmr/migrations/2025-03-23-132320_add_created_at/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE bookmarks DROP COLUMN created_ts; 3 | ALTER TABLE bookmarks DROP COLUMN embeddable; 4 | 5 | -------------------------------------------------------------------------------- /bkmr/migrations/2025-03-23-132320_add_created_at/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE bookmarks ADD COLUMN created_ts DATETIME DEFAULT NULL; 3 | ALTER TABLE bookmarks ADD COLUMN embeddable BOOLEAN NOT NULL DEFAULT 0; -------------------------------------------------------------------------------- /bkmr/migrations/README.md: -------------------------------------------------------------------------------- 1 | # Diesel Migrations 2 | 3 | diesel migration generate add_embedding_and_content_hash_to_bookmarks 4 | 5 | /Users/Q187392/.cargo/bin/diesel --database-url sqlite://../db/bkmr.db print-schema 6 | Out: Diesel only supports tables with primary keys. Table bookmarks_fts has no primary key 7 | 8 | /Users/Q187392/.cargo/bin/diesel --database-url sqlite://../db/bkmr.db print-schema --except-tables bookmarks_fts 9 | // @generated automatically by Diesel CLI. 10 | 11 | diesel::table! { 12 | bookmarks (id) { 13 | id -> Integer, 14 | URL -> Text, 15 | metadata -> Text, 16 | tags -> Text, 17 | desc -> Text, 18 | flags -> Integer, 19 | last_update_ts -> Timestamp, 20 | embedding -> Nullable, 21 | content_hash -> Nullable, 22 | } 23 | } 24 | 25 | 26 | /Users/Q187392/.cargo/bin/diesel --database-url sqlite://../db/bkmr.db migration run 27 | /Users/Q187392/.cargo/bin/diesel --database-url sqlite://../db/bkmr.db migration revert 28 | 29 | -------------------------------------------------------------------------------- /bkmr/src/application/actions/default_action.rs: -------------------------------------------------------------------------------- 1 | // src/application/actions/default_action.rs 2 | use crate::application::services::interpolation::InterpolationService; 3 | use crate::domain::action::BookmarkAction; 4 | use crate::domain::bookmark::Bookmark; 5 | use crate::domain::error::{DomainError, DomainResult}; 6 | use std::sync::Arc; 7 | use tracing::{debug, instrument}; 8 | 9 | #[derive(Debug)] 10 | pub struct DefaultAction { 11 | interpolation_service: Arc, 12 | } 13 | 14 | impl DefaultAction { 15 | pub fn new(interpolation_service: Arc) -> Self { 16 | Self { 17 | interpolation_service, 18 | } 19 | } 20 | } 21 | 22 | impl BookmarkAction for DefaultAction { 23 | #[instrument(skip(self, bookmark), level = "debug")] 24 | fn execute(&self, bookmark: &Bookmark) -> DomainResult<()> { 25 | // Default action falls back to treating the bookmark as a URI 26 | 27 | // Render the URL with interpolation if needed 28 | let rendered_url = self 29 | .interpolation_service 30 | .render_bookmark_url(bookmark) 31 | .map_err(|e| DomainError::Other(format!("Failed to render URL: {}", e)))?; 32 | 33 | // Open the URL in default browser/application 34 | debug!("Opening with default application: {}", rendered_url); 35 | open::that(&rendered_url) 36 | .map_err(|e| DomainError::Other(format!("Failed to open: {}", e)))?; 37 | 38 | Ok(()) 39 | } 40 | 41 | fn description(&self) -> &'static str { 42 | "Open with default application" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /bkmr/src/application/actions/mod.rs: -------------------------------------------------------------------------------- 1 | // src/application/actions/mod.rs 2 | pub mod default_action; 3 | pub mod env_action; 4 | pub mod markdown_action; 5 | pub mod shell_action; 6 | pub mod snippet_action; 7 | pub mod text_action; 8 | pub mod uri_action; 9 | 10 | pub use default_action::DefaultAction; 11 | pub use env_action::EnvAction; 12 | pub use markdown_action::MarkdownAction; 13 | pub use shell_action::ShellAction; 14 | pub use snippet_action::SnippetAction; 15 | pub use text_action::TextAction; 16 | pub use uri_action::UriAction; 17 | -------------------------------------------------------------------------------- /bkmr/src/application/actions/snippet_action.rs: -------------------------------------------------------------------------------- 1 | // src/application/actions/snippet_action.rs 2 | use crate::application::services::interpolation::InterpolationService; 3 | use crate::domain::action::BookmarkAction; 4 | use crate::domain::bookmark::Bookmark; 5 | use crate::domain::error::{DomainError, DomainResult}; 6 | use crate::domain::services::clipboard::ClipboardService; 7 | use std::sync::Arc; 8 | use tracing::{debug, instrument}; 9 | 10 | #[derive(Debug)] 11 | pub struct SnippetAction { 12 | clipboard_service: Arc, 13 | interpolation_service: Arc, 14 | } 15 | 16 | impl SnippetAction { 17 | pub fn new( 18 | clipboard_service: Arc, 19 | interpolation_service: Arc, 20 | ) -> Self { 21 | debug!("Creating new SnippetAction"); 22 | Self { 23 | clipboard_service, 24 | interpolation_service, 25 | } 26 | } 27 | } 28 | 29 | impl BookmarkAction for SnippetAction { 30 | #[instrument(skip(self, bookmark), level = "debug", 31 | fields(bookmark_id = ?bookmark.id, bookmark_title = %bookmark.title))] 32 | fn execute(&self, bookmark: &Bookmark) -> DomainResult<()> { 33 | // Get the snippet content - this is stored in the URL field for snippet bookmarks 34 | let content = bookmark.snippet_content(); 35 | 36 | // Apply any interpolation if the snippet contains template variables 37 | let rendered_content = if content.contains("{{") || content.contains("{%") { 38 | self.interpolation_service 39 | .render_bookmark_url(bookmark) 40 | .map_err(|e| DomainError::Other(format!("Failed to render snippet: {}", e)))? 41 | } else { 42 | content.to_string() 43 | }; 44 | 45 | eprintln!("Copied to clipboard:\n{}", rendered_content); 46 | // Copy to clipboard 47 | self.clipboard_service 48 | .copy_to_clipboard(&rendered_content)?; 49 | 50 | // Optionally, we could print a confirmation message here, but that's UI logic 51 | // and should be handled at the CLI layer 52 | 53 | Ok(()) 54 | } 55 | 56 | fn description(&self) -> &'static str { 57 | "Copy to clipboard" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /bkmr/src/application/actions/text_action.rs: -------------------------------------------------------------------------------- 1 | use crate::application::services::interpolation::InterpolationService; 2 | // src/application/actions/text_action.rs 3 | use crate::domain::action::BookmarkAction; 4 | use crate::domain::bookmark::Bookmark; 5 | use crate::domain::error::{DomainError, DomainResult}; 6 | use crate::domain::services::clipboard::ClipboardService; 7 | use std::sync::Arc; 8 | use tracing::instrument; 9 | 10 | #[derive(Debug)] 11 | pub struct TextAction { 12 | clipboard_service: Arc, 13 | interpolation_service: Arc, 14 | } 15 | 16 | impl TextAction { 17 | pub fn new( 18 | clipboard_service: Arc, 19 | interpolation_service: Arc, 20 | ) -> Self { 21 | Self { 22 | clipboard_service, 23 | interpolation_service, 24 | } 25 | } 26 | } 27 | 28 | impl BookmarkAction for TextAction { 29 | #[instrument(skip(self, bookmark), level = "debug")] 30 | fn execute(&self, bookmark: &Bookmark) -> DomainResult<()> { 31 | // For text bookmarks, the behavior is similar to snippets 32 | // but the context and usage might be different 33 | 34 | // Get the content (stored in URL field for imported text) 35 | let content = &bookmark.url; 36 | 37 | // Apply any interpolation if the text contains template variables 38 | let rendered_content = if content.contains("{{") || content.contains("{%") { 39 | self.interpolation_service 40 | .render_bookmark_url(bookmark) 41 | .map_err(|e| DomainError::Other(format!("Failed to render text: {}", e)))? 42 | } else { 43 | content.to_string() 44 | }; 45 | 46 | // Copy to clipboard 47 | self.clipboard_service 48 | .copy_to_clipboard(&rendered_content)?; 49 | 50 | Ok(()) 51 | } 52 | 53 | fn description(&self) -> &'static str { 54 | "Copy text to clipboard" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /bkmr/src/application/actions/uri_action.rs: -------------------------------------------------------------------------------- 1 | // src/application/actions/uri_action.rs 2 | use crate::application::services::interpolation::InterpolationService; 3 | use crate::domain::action::BookmarkAction; 4 | use crate::domain::bookmark::Bookmark; 5 | use crate::domain::error::{DomainError, DomainResult}; 6 | use crossterm::style::Stylize; 7 | use std::sync::Arc; 8 | use tracing::{debug, instrument}; 9 | 10 | #[derive(Debug)] 11 | pub struct UriAction { 12 | interpolation_service: Arc, 13 | } 14 | 15 | impl UriAction { 16 | pub fn new(interpolation_service: Arc) -> Self { 17 | Self { 18 | interpolation_service, 19 | } 20 | } 21 | 22 | // Helper method to open a URL with proper rendering 23 | #[instrument(skip(self, url), level = "debug")] 24 | fn open_url(&self, url: &str) -> DomainResult<()> { 25 | debug!("Opening URL: {}", url); 26 | 27 | if url.starts_with("shell::") { 28 | // Extract the shell command 29 | let cmd = url.replace("shell::", ""); 30 | eprintln!("Executing shell command: {}", cmd); 31 | eprintln!( 32 | "{}", 33 | "'shell::' is deprecated. Use SystemTag '_shell_' instead.".yellow() 34 | ); 35 | 36 | // Create a child process with inherited stdio 37 | let mut child = std::process::Command::new("sh") 38 | .arg("-c") 39 | .arg(cmd) 40 | .stdin(std::process::Stdio::inherit()) 41 | .stdout(std::process::Stdio::inherit()) 42 | .stderr(std::process::Stdio::inherit()) 43 | .spawn() 44 | .map_err(|e| { 45 | DomainError::Other(format!("Failed to execute shell command: {}", e)) 46 | })?; 47 | 48 | // Wait for the process to complete 49 | let status = child 50 | .wait() 51 | .map_err(|e| DomainError::Other(format!("Failed to wait on command: {}", e)))?; 52 | 53 | debug!("Shell command exit status: {:?}", status); 54 | return Ok(()); 55 | } 56 | 57 | // Handle regular URLs or file paths 58 | if let Some(path) = crate::util::path::abspath(url) { 59 | debug!("Resolved path: {}", path); 60 | 61 | // Check if it's a markdown file 62 | if path.ends_with(".md") { 63 | debug!("Opening markdown file with editor: {}", path); 64 | let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string()); 65 | debug!("Using editor: {}", editor); 66 | 67 | std::process::Command::new(editor) 68 | .arg(&path) 69 | .status() 70 | .map_err(|e| { 71 | DomainError::Other(format!("Failed to open with editor: {}", e)) 72 | })?; 73 | } else { 74 | debug!("Opening file with default OS application: {}", path); 75 | open::that(&path) 76 | .map_err(|e| DomainError::Other(format!("Failed to open file: {}", e)))?; 77 | } 78 | } else { 79 | debug!("Opening URL with default OS command: {}", url); 80 | open::that(url) 81 | .map_err(|e| DomainError::Other(format!("Failed to open URL: {}", e)))?; 82 | } 83 | 84 | Ok(()) 85 | } 86 | } 87 | 88 | impl BookmarkAction for UriAction { 89 | #[instrument(skip(self, bookmark), level = "debug")] 90 | fn execute(&self, bookmark: &Bookmark) -> DomainResult<()> { 91 | // First record access 92 | // This should be done at the service level, not here 93 | // We'll add this to the action service 94 | 95 | // Render the URL with interpolation if needed 96 | let rendered_url = self 97 | .interpolation_service 98 | .render_bookmark_url(bookmark) 99 | .map_err(|e| DomainError::Other(format!("Failed to render URL: {}", e)))?; 100 | 101 | // Open the URL 102 | self.open_url(&rendered_url) 103 | } 104 | 105 | fn description(&self) -> &'static str { 106 | "Open in browser or application" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /bkmr/src/application/error.rs: -------------------------------------------------------------------------------- 1 | // bkmr/src/application/error.rs 2 | use crate::domain::error::DomainError; 3 | use thiserror::Error; 4 | 5 | #[derive(Error, Debug)] 6 | pub enum ApplicationError { 7 | #[error("Domain error: {0}")] 8 | Domain(#[from] DomainError), 9 | 10 | #[error("Bookmark not found with ID {0}")] 11 | BookmarkNotFound(i32), 12 | 13 | #[error("Bookmark already exists: Id {0}: {1}")] 14 | BookmarkExists(i32, String), 15 | 16 | #[error("Validation failed: {0}")] 17 | Validation(String), 18 | 19 | #[error("{0}")] 20 | Other(String), 21 | } 22 | 23 | // Add a context method for ApplicationError 24 | impl ApplicationError { 25 | pub fn context>(self, context: C) -> Self { 26 | match self { 27 | ApplicationError::Other(msg) => { 28 | ApplicationError::Other(format!("{}: {}", context.into(), msg)) 29 | } 30 | ApplicationError::Domain(err) => ApplicationError::Domain(err.context(context)), 31 | ApplicationError::Validation(msg) => { 32 | ApplicationError::Validation(format!("{}: {}", context.into(), msg)) 33 | } 34 | err => ApplicationError::Other(format!("{}: {}", context.into(), err)), 35 | } 36 | } 37 | } 38 | 39 | impl From for ApplicationError { 40 | fn from(err: std::io::Error) -> Self { 41 | ApplicationError::Domain(DomainError::Io(err)) 42 | } 43 | } 44 | 45 | impl From for ApplicationError { 46 | fn from(err: std::time::SystemTimeError) -> Self { 47 | ApplicationError::Other(format!("System time error: {}", err)) 48 | } 49 | } 50 | 51 | pub type ApplicationResult = Result; 52 | -------------------------------------------------------------------------------- /bkmr/src/application/mod.rs: -------------------------------------------------------------------------------- 1 | // bkmr/src/application/mod.rs 2 | mod actions; 3 | pub mod error; 4 | pub mod services; 5 | pub mod templates; 6 | 7 | // Re-export key services for easier imports 8 | pub use services::bookmark_service_impl::BookmarkServiceImpl; 9 | pub use services::interpolation::InterpolationServiceImpl; 10 | pub use services::tag_service_impl::TagServiceImpl; 11 | pub use services::template_service::TemplateServiceImpl; 12 | -------------------------------------------------------------------------------- /bkmr/src/application/services/action_service.rs: -------------------------------------------------------------------------------- 1 | // src/application/services/action_service.rs 2 | use crate::domain::action_resolver::ActionResolver; 3 | use crate::domain::bookmark::Bookmark; 4 | use crate::domain::error::{DomainError, DomainResult}; 5 | use crate::domain::repositories::repository::BookmarkRepository; 6 | use std::sync::Arc; 7 | use tracing::{debug, instrument}; 8 | 9 | /// Service for executing actions on bookmarks 10 | pub trait ActionService: Send + Sync { 11 | /// Executes the default action for a bookmark 12 | fn execute_default_action(&self, bookmark: &Bookmark) -> DomainResult<()>; 13 | 14 | /// Executes the default action for a bookmark by ID 15 | fn execute_default_action_by_id(&self, id: i32) -> DomainResult<()>; 16 | 17 | /// Gets a description of the default action for a bookmark 18 | fn get_default_action_description(&self, bookmark: &Bookmark) -> &'static str; 19 | } 20 | 21 | /// Implementation of ActionService that uses an ActionResolver 22 | pub struct ActionServiceImpl { 23 | resolver: Arc, 24 | repository: Arc, 25 | } 26 | 27 | impl ActionServiceImpl { 28 | pub fn new(resolver: Arc, repository: Arc) -> Self { 29 | Self { 30 | resolver, 31 | repository, 32 | } 33 | } 34 | } 35 | 36 | impl ActionService for ActionServiceImpl { 37 | #[instrument(skip(self, bookmark), level = "debug")] 38 | fn execute_default_action(&self, bookmark: &Bookmark) -> DomainResult<()> { 39 | // First, record the access (increase access count) 40 | if let Some(id) = bookmark.id { 41 | debug!("Recording access for bookmark {}", id); 42 | self.record_bookmark_access(id)?; 43 | } 44 | 45 | // Resolve and execute the appropriate action 46 | let action = self.resolver.resolve_action(bookmark); 47 | debug!("Executing action: {}", action.description()); 48 | action.execute(bookmark) 49 | } 50 | 51 | #[instrument(skip(self), level = "debug")] 52 | fn execute_default_action_by_id(&self, id: i32) -> DomainResult<()> { 53 | // Get the bookmark 54 | let bookmark = self 55 | .repository 56 | .get_by_id(id)? 57 | .ok_or_else(|| DomainError::BookmarkNotFound(id.to_string()))?; 58 | 59 | // Execute the default action 60 | self.execute_default_action(&bookmark) 61 | } 62 | 63 | fn get_default_action_description(&self, bookmark: &Bookmark) -> &'static str { 64 | let action = self.resolver.resolve_action(bookmark); 65 | action.description() 66 | } 67 | } 68 | 69 | // Helper methods 70 | impl ActionServiceImpl { 71 | // Record that a bookmark was accessed 72 | #[instrument(skip(self), level = "trace")] 73 | fn record_bookmark_access(&self, id: i32) -> DomainResult<()> { 74 | let mut bookmark = self 75 | .repository 76 | .get_by_id(id)? 77 | .ok_or_else(|| DomainError::BookmarkNotFound(id.to_string()))?; 78 | 79 | bookmark.record_access(); 80 | 81 | self.repository.update(&bookmark)?; 82 | 83 | Ok(()) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /bkmr/src/application/services/bookmark_service.rs: -------------------------------------------------------------------------------- 1 | // src/application/services/bookmark_service.rs 2 | use crate::application::error::ApplicationResult; 3 | use crate::domain::bookmark::Bookmark; 4 | use crate::domain::repositories::query::{BookmarkQuery, SortDirection}; 5 | use crate::domain::search::{SemanticSearch, SemanticSearchResult}; 6 | use crate::domain::tag::Tag; 7 | use std::collections::HashSet; 8 | use std::fmt::Debug; 9 | 10 | /// Service interface for bookmark-related operations 11 | pub trait BookmarkService: Send + Sync + Debug { 12 | /// Add a new bookmark 13 | fn add_bookmark( 14 | &self, 15 | url: &str, 16 | title: Option<&str>, 17 | description: Option<&str>, 18 | tags: Option<&HashSet>, 19 | fetch_metadata: bool, 20 | ) -> ApplicationResult; 21 | 22 | /// Delete a bookmark by ID 23 | fn delete_bookmark(&self, id: i32) -> ApplicationResult; 24 | 25 | /// Get a bookmark by ID 26 | fn get_bookmark(&self, id: i32) -> ApplicationResult>; 27 | 28 | fn set_bookmark_embeddable(&self, id: i32, embeddable: bool) -> ApplicationResult; 29 | 30 | /// Update a bookmark's title and description 31 | fn update_bookmark( 32 | &self, 33 | bookmark: Bookmark, 34 | force_embedding: bool, 35 | ) -> ApplicationResult; 36 | 37 | /// Add tags to a bookmark 38 | fn add_tags_to_bookmark(&self, id: i32, tags: &HashSet) -> ApplicationResult; 39 | 40 | /// Remove tags from a bookmark 41 | fn remove_tags_from_bookmark( 42 | &self, 43 | id: i32, 44 | tags: &HashSet, 45 | ) -> ApplicationResult; 46 | 47 | /// Replace all tags on a bookmark 48 | fn replace_bookmark_tags(&self, id: i32, tags: &HashSet) -> ApplicationResult; 49 | 50 | fn search_bookmarks_by_text(&self, query: &str) -> ApplicationResult>; 51 | // Add a convenience method to create a query for text search 52 | 53 | // Replace the complex search_bookmarks method with a simpler interface 54 | fn search_bookmarks(&self, query: &BookmarkQuery) -> ApplicationResult>; 55 | 56 | /// Perform semantic search with the given parameters 57 | fn semantic_search( 58 | &self, 59 | search: &SemanticSearch, 60 | ) -> ApplicationResult>; 61 | 62 | /// Get bookmark by URL 63 | fn get_bookmark_by_url(&self, url: &str) -> ApplicationResult>; 64 | 65 | /// Get all bookmarks 66 | fn get_all_bookmarks( 67 | &self, 68 | sort_direction: Option, 69 | limit: Option, 70 | ) -> ApplicationResult>; 71 | 72 | /// Get random bookmarks 73 | fn get_random_bookmarks(&self, count: usize) -> ApplicationResult>; 74 | 75 | /// Get bookmarks for forced backfill (all embeddable bookmarks except those with _imported_ tag) 76 | fn get_bookmarks_for_forced_backfill(&self) -> ApplicationResult>; 77 | 78 | /// Check if bookmarks need embedding backfilling 79 | fn get_bookmarks_without_embeddings(&self) -> ApplicationResult>; 80 | 81 | /// Record that a bookmark was accessed 82 | fn record_bookmark_access(&self, id: i32) -> ApplicationResult; 83 | 84 | /// Import bookmarks from a JSON file 85 | fn load_json_bookmarks(&self, path: &str, dry_run: bool) -> ApplicationResult; 86 | 87 | /// Load texts from NDJSON file and create embeddings for semantic search 88 | fn load_texts(&self, path: &str, dry_run: bool, force: bool) -> ApplicationResult; 89 | } 90 | -------------------------------------------------------------------------------- /bkmr/src/application/services/interpolation.rs: -------------------------------------------------------------------------------- 1 | // src/application/services/interpolation_service.rs 2 | use crate::application::error::{ApplicationError, ApplicationResult}; 3 | use crate::domain::bookmark::Bookmark; 4 | use crate::domain::interpolation::interface::InterpolationEngine; 5 | use std::fmt::Debug; 6 | use std::sync::Arc; 7 | use tracing::instrument; 8 | 9 | /// Service interface for interpolation-related operations 10 | pub trait InterpolationService: Send + Sync + Debug { 11 | /// Render an interpolated URL within the context of a bookmark 12 | fn render_bookmark_url(&self, bookmark: &Bookmark) -> ApplicationResult; 13 | 14 | /// Render an interpolated URL without any specific context 15 | fn render_url(&self, url: &str) -> ApplicationResult; 16 | } 17 | 18 | /// Implementation of InterpolationService using a template engine 19 | #[derive(Debug)] 20 | pub struct InterpolationServiceImpl { 21 | pub interpolation_engine: Arc, 22 | } 23 | 24 | impl InterpolationServiceImpl { 25 | pub fn new(template_engine: Arc) -> Self { 26 | Self { 27 | interpolation_engine: template_engine, 28 | } 29 | } 30 | } 31 | 32 | impl InterpolationService for InterpolationServiceImpl { 33 | #[instrument(level = "debug", skip(self, bookmark))] 34 | fn render_bookmark_url(&self, bookmark: &Bookmark) -> ApplicationResult { 35 | self.interpolation_engine 36 | .render_bookmark_url(bookmark) 37 | .map_err(|e| ApplicationError::Other(format!("Template rendering error: {}", e))) 38 | } 39 | 40 | #[instrument(level = "debug", skip(self))] 41 | fn render_url(&self, url: &str) -> ApplicationResult { 42 | self.interpolation_engine 43 | .render_url(url) 44 | .map_err(|e| ApplicationError::Other(format!("Template rendering error: {}", e))) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /bkmr/src/application/services/mod.rs: -------------------------------------------------------------------------------- 1 | // src/application/services/mod.rs 2 | pub mod action_service; 3 | pub mod bookmark_service; 4 | pub mod bookmark_service_impl; 5 | pub mod factory; 6 | pub mod interpolation; 7 | pub mod tag_service; 8 | pub mod tag_service_impl; 9 | pub mod template_service; 10 | 11 | // Re-export service interfaces 12 | pub use action_service::ActionService; 13 | pub use bookmark_service::BookmarkService; 14 | pub use interpolation::InterpolationService; 15 | pub use tag_service::TagService; 16 | pub use template_service::TemplateService; 17 | 18 | // Re-export service implementations 19 | pub use bookmark_service_impl::BookmarkServiceImpl; 20 | pub use interpolation::InterpolationServiceImpl; 21 | pub use tag_service_impl::TagServiceImpl; 22 | pub use template_service::TemplateServiceImpl; 23 | -------------------------------------------------------------------------------- /bkmr/src/application/services/tag_service.rs: -------------------------------------------------------------------------------- 1 | // src/application/services/tag_service.rs 2 | use crate::application::error::ApplicationResult; 3 | use crate::domain::tag::Tag; 4 | 5 | /// Service interface for tag-related operations 6 | pub trait TagService: Send + Sync { 7 | /// Get all tags with their usage counts 8 | fn get_all_tags(&self) -> ApplicationResult>; 9 | 10 | /// Get tags related to the given tag 11 | fn get_related_tags(&self, tag: &Tag) -> ApplicationResult>; 12 | 13 | /// Parse a tag string and create Tag objects 14 | fn parse_tag_string(&self, tag_str: &str) -> ApplicationResult>; 15 | } 16 | -------------------------------------------------------------------------------- /bkmr/src/application/templates/mod.rs: -------------------------------------------------------------------------------- 1 | // src/application/templates/mod.rs 2 | pub mod bookmark_template; 3 | -------------------------------------------------------------------------------- /bkmr/src/cli/error.rs: -------------------------------------------------------------------------------- 1 | // src/cli/error.rs 2 | use crate::application::error::ApplicationError; 3 | use crate::domain::error::DomainError; 4 | use std::io; 5 | use thiserror::Error; 6 | 7 | #[derive(Error, Debug)] 8 | pub enum CliError { 9 | #[error("Command failed: {0}")] 10 | CommandFailed(String), 11 | 12 | #[error("Invalid input: {0}")] 13 | InvalidInput(String), 14 | 15 | #[error("Invalid ID format: {0}")] 16 | InvalidIdFormat(String), 17 | 18 | #[error("Operation aborted by user")] 19 | OperationAborted, 20 | 21 | #[error("Application error: {0}")] 22 | Application(#[from] ApplicationError), 23 | 24 | #[error("IO error: {0}")] 25 | Io(#[from] io::Error), 26 | 27 | #[error("{0}")] 28 | Other(String), 29 | } 30 | 31 | // Add context method to CliError 32 | impl CliError { 33 | pub fn context>(self, context: C) -> Self { 34 | match self { 35 | CliError::CommandFailed(msg) => { 36 | CliError::CommandFailed(format!("{}: {}", context.into(), msg)) 37 | } 38 | CliError::InvalidInput(msg) => { 39 | CliError::InvalidInput(format!("{}: {}", context.into(), msg)) 40 | } 41 | CliError::Application(err) => CliError::Application(err.context(context)), 42 | CliError::Other(msg) => CliError::Other(format!("{}: {}", context.into(), msg)), 43 | err => CliError::Other(format!("{}: {}", context.into(), err)), 44 | } 45 | } 46 | } 47 | 48 | // Direct conversion from DomainError to CliError (via ApplicationError) 49 | impl From for CliError { 50 | fn from(err: DomainError) -> Self { 51 | CliError::Application(ApplicationError::Domain(err)) 52 | } 53 | } 54 | 55 | impl From for CliError { 56 | fn from( 57 | err: crate::infrastructure::repositories::sqlite::error::SqliteRepositoryError, 58 | ) -> Self { 59 | // Convert via DomainError which already has a From implementation for SqliteRepositoryError 60 | CliError::Application(ApplicationError::Domain(err.into())) 61 | } 62 | } 63 | 64 | pub type CliResult = Result; 65 | -------------------------------------------------------------------------------- /bkmr/src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | // bkmr/src/cli/mod.rs 2 | use crate::cli::args::{Cli, Commands}; 3 | use crate::cli::error::CliResult; 4 | use termcolor::StandardStream; 5 | 6 | pub mod args; 7 | pub mod bookmark_commands; 8 | pub mod completion; 9 | pub mod display; 10 | pub mod error; 11 | pub mod fzf; 12 | pub mod process; 13 | pub mod tag_commands; 14 | 15 | pub fn execute_command(stderr: StandardStream, cli: Cli) -> CliResult<()> { 16 | if cli.generate_config { 17 | println!("{}", crate::config::generate_default_config()); 18 | return Ok(()); 19 | } 20 | match cli.command { 21 | Some(Commands::Search { .. }) => bookmark_commands::search(stderr, cli), 22 | Some(Commands::SemSearch { .. }) => bookmark_commands::semantic_search(stderr, cli), 23 | Some(Commands::Open { .. }) => bookmark_commands::open(cli), 24 | Some(Commands::Add { .. }) => bookmark_commands::add(cli), 25 | Some(Commands::Delete { .. }) => bookmark_commands::delete(cli), 26 | Some(Commands::Update { .. }) => bookmark_commands::update(cli), 27 | Some(Commands::Edit { .. }) => bookmark_commands::edit(cli), 28 | Some(Commands::Show { .. }) => bookmark_commands::show(cli), 29 | Some(Commands::Tags { .. }) => tag_commands::show_tags(cli), 30 | Some(Commands::Surprise { .. }) => bookmark_commands::surprise(cli), 31 | Some(Commands::CreateDb { .. }) => bookmark_commands::create_db(cli), 32 | Some(Commands::SetEmbeddable { .. }) => bookmark_commands::set_embeddable(cli), 33 | Some(Commands::Backfill { .. }) => bookmark_commands::backfill(cli), 34 | Some(Commands::LoadTexts { .. }) => bookmark_commands::load_texts(cli), 35 | Some(Commands::LoadJson { .. }) => bookmark_commands::load_json(cli), 36 | Some(Commands::Info { .. }) => bookmark_commands::info(cli), 37 | Some(Commands::Completion { shell }) => handle_completion(shell), 38 | Some(Commands::Xxx { ids, tags }) => { 39 | eprintln!("ids: {:?}, tags: {:?}", ids, tags); 40 | Ok(()) 41 | } 42 | None => Ok(()), 43 | } 44 | } 45 | 46 | fn handle_completion(shell: String) -> CliResult<()> { 47 | // Write a brief comment to stderr about what's being output 48 | match shell.to_lowercase().as_str() { 49 | "bash" => { 50 | eprintln!("# Outputting bash completion script for bkmr"); 51 | eprintln!("# To use, run one of:"); 52 | eprintln!("# - eval \"$(bkmr completion bash)\" # one-time use"); 53 | eprintln!("# - bkmr completion bash >> ~/.bashrc # add to bashrc"); 54 | eprintln!( 55 | "# - bkmr completion bash > /etc/bash_completion.d/bkmr # system-wide install" 56 | ); 57 | eprintln!("#"); 58 | } 59 | "zsh" => { 60 | eprintln!("# Outputting zsh completion script for bkmr"); 61 | eprintln!("# To use, run one of:"); 62 | eprintln!("# - eval \"$(bkmr completion zsh)\" # one-time use"); 63 | eprintln!( 64 | "# - bkmr completion zsh > ~/.zfunc/_bkmr # save to fpath directory" 65 | ); 66 | eprintln!("# - echo 'fpath=(~/.zfunc $fpath)' >> ~/.zshrc # add dir to fpath if needed"); 67 | eprintln!("# - echo 'autoload -U compinit && compinit' >> ~/.zshrc # load completions"); 68 | eprintln!("#"); 69 | } 70 | "fish" => { 71 | eprintln!("# Outputting fish completion script for bkmr"); 72 | eprintln!("# To use, run one of:"); 73 | eprintln!("# - bkmr completion fish | source # one-time use"); 74 | eprintln!("# - bkmr completion fish > ~/.config/fish/completions/bkmr.fish # permanent install"); 75 | eprintln!("#"); 76 | } 77 | _ => {} 78 | } 79 | 80 | // Generate completion script to stdout 81 | match completion::generate_completion(&shell) { 82 | Ok(_) => Ok(()), 83 | Err(e) => Err(error::CliError::CommandFailed(format!( 84 | "Failed to generate completion script: {}", 85 | e 86 | ))), 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /bkmr/src/cli/tag_commands.rs: -------------------------------------------------------------------------------- 1 | // src/cli/tag_commands.rs 2 | use crate::application::services::factory; 3 | use crate::cli::args::{Cli, Commands}; 4 | use crate::cli::error::CliResult; 5 | use crate::domain::tag::Tag; 6 | use crate::util::helper::is_stdout_piped; 7 | use crossterm::style::Stylize; 8 | 9 | pub fn show_tags(cli: Cli) -> CliResult<()> { 10 | if let Commands::Tags { tag } = cli.command.unwrap() { 11 | let tag_service = factory::create_tag_service(); 12 | 13 | // Determine if stdout is being piped to another process 14 | let is_piped = is_stdout_piped(); 15 | 16 | match tag { 17 | Some(tag_str) => { 18 | // Show related tags for the specified tag 19 | let parsed_tag = Tag::new(&tag_str)?; 20 | let related_tags = tag_service.get_related_tags(&parsed_tag)?; 21 | 22 | if related_tags.is_empty() { 23 | eprintln!("No related tags found for '{}'", tag_str.blue()); 24 | } else { 25 | eprintln!("Tags related to '{}':", tag_str.blue()); 26 | 27 | // Sort by count (most frequent first) 28 | let mut sorted_tags = related_tags; 29 | sorted_tags.sort_by(|(_, count_a), (_, count_b)| count_b.cmp(count_a)); 30 | 31 | // let output = String::new(); 32 | for (tag, count) in sorted_tags { 33 | if is_piped { 34 | // Plain text for piping 35 | println!("{} ({})", tag.value(), count); 36 | } else { 37 | // Colored text for terminal 38 | println!("{} ({})", tag.value().green(), count); 39 | } 40 | } 41 | } 42 | } 43 | None => { 44 | // Show all tags 45 | let all_tags = tag_service.get_all_tags()?; 46 | 47 | if all_tags.is_empty() { 48 | eprintln!("No tags found"); 49 | } else { 50 | eprintln!("All tags:"); 51 | 52 | // Sort by count (most frequent first) 53 | let mut sorted_tags = all_tags; 54 | sorted_tags.sort_by(|(_, count_a), (_, count_b)| count_b.cmp(count_a)); 55 | 56 | // let output = String::new(); 57 | for (tag, count) in sorted_tags { 58 | if is_piped { 59 | // Plain text for piping 60 | println!("{} ({})", tag.value(), count); 61 | } else { 62 | // Colored text for terminal 63 | println!("{} ({})", tag.value().green(), count); 64 | } 65 | } 66 | } 67 | } 68 | } 69 | } 70 | 71 | Ok(()) 72 | } 73 | 74 | #[cfg(test)] 75 | mod tests { 76 | // Tests would be added here 77 | } 78 | -------------------------------------------------------------------------------- /bkmr/src/default_config.toml: -------------------------------------------------------------------------------- 1 | # src/default_config.toml 2 | db_url = "../db/bkmr.db" 3 | 4 | # Available FZF options: 5 | # --height 50% Set the height of the finder 6 | # --reverse Display results in reverse order 7 | # --show-tags Show tags in the display 8 | # --no-url Hide URLs in the display 9 | # --no-action Hide default actions in the display 10 | [fzf_opts] 11 | height = "50%" 12 | reverse = false 13 | show_tags = false 14 | no_url = false 15 | show_action = true -------------------------------------------------------------------------------- /bkmr/src/domain/action.rs: -------------------------------------------------------------------------------- 1 | // src/domain/action.rs 2 | use crate::domain::bookmark::Bookmark; 3 | use crate::domain::error::DomainResult; 4 | use std::fmt::Debug; 5 | 6 | /// Defines an action that can be performed on a bookmark 7 | pub trait BookmarkAction: Debug + Send + Sync { 8 | /// Executes the default action for a bookmark 9 | fn execute(&self, bookmark: &Bookmark) -> DomainResult<()>; 10 | 11 | /// Returns a description of the action for UI purposes 12 | fn description(&self) -> &'static str; 13 | } 14 | -------------------------------------------------------------------------------- /bkmr/src/domain/error.rs: -------------------------------------------------------------------------------- 1 | // src/domain/error.rs 2 | use crate::domain::bookmark::BookmarkBuilderError; 3 | use crate::domain::interpolation; 4 | use thiserror::Error; 5 | 6 | #[derive(Error, Debug)] 7 | pub enum DomainError { 8 | #[error("Invalid URL: {0}")] 9 | InvalidUrl(String), 10 | 11 | #[error("Invalid tag: {0}")] 12 | InvalidTag(String), 13 | 14 | #[error("Tag operation failed: {0}")] 15 | TagOperationFailed(String), 16 | 17 | #[error("Bookmark operation failed: {0}")] 18 | BookmarkOperationFailed(String), 19 | 20 | #[error("Bookmark not found: {0}")] 21 | BookmarkNotFound(String), 22 | 23 | #[error("Cannot fetch metadata: {0}")] 24 | CannotFetchMetadata(String), 25 | 26 | #[error("Repository error: {0}")] 27 | RepositoryError(#[from] RepositoryError), 28 | 29 | #[error("Interpolation error: {0}")] 30 | Interpolation(#[from] interpolation::errors::InterpolationError), 31 | 32 | #[error("IO error: {0}")] 33 | Io(#[from] std::io::Error), 34 | 35 | #[error("Serialization error: {0}")] 36 | SerializationError(String), 37 | 38 | #[error("Deserialization error: {0}")] 39 | DeserializationError(String), 40 | 41 | #[error("{0}")] 42 | Other(String), 43 | } 44 | 45 | // New repository error enum to represent generic repository errors 46 | #[derive(Error, Debug)] 47 | pub enum RepositoryError { 48 | #[error("Entity not found: {0}")] 49 | NotFound(String), 50 | 51 | #[error("Database error: {0}")] 52 | Database(String), 53 | 54 | #[error("Connection error: {0}")] 55 | Connection(String), 56 | 57 | #[error("Query error: {0}")] 58 | Query(String), 59 | 60 | #[error("Constraint violation: {0}")] 61 | Constraint(String), 62 | 63 | #[error("Repository error: {0}")] 64 | Other(String), 65 | } 66 | 67 | // Add a context method to DomainError for better error context 68 | impl DomainError { 69 | pub fn context>(self, context: C) -> Self { 70 | match self { 71 | DomainError::Other(msg) => DomainError::Other(format!("{}: {}", context.into(), msg)), 72 | DomainError::BookmarkOperationFailed(msg) => { 73 | DomainError::BookmarkOperationFailed(format!("{}: {}", context.into(), msg)) 74 | } 75 | DomainError::RepositoryError(err) => { 76 | DomainError::RepositoryError(RepositoryError::context(err, context)) 77 | } 78 | // Add more specific handling for other error types as needed 79 | err => DomainError::Other(format!("{}: {}", context.into(), err)), 80 | } 81 | } 82 | } 83 | 84 | // Add a context method to RepositoryError 85 | impl RepositoryError { 86 | pub fn context>(self, context: C) -> Self { 87 | match self { 88 | RepositoryError::Other(msg) => { 89 | RepositoryError::Other(format!("{}: {}", context.into(), msg)) 90 | } 91 | RepositoryError::Database(msg) => { 92 | RepositoryError::Database(format!("{}: {}", context.into(), msg)) 93 | } 94 | RepositoryError::NotFound(msg) => { 95 | RepositoryError::NotFound(format!("{}: {}", context.into(), msg)) 96 | } 97 | err => RepositoryError::Other(format!("{}: {}", context.into(), err)), 98 | } 99 | } 100 | } 101 | 102 | // Common result type 103 | pub type DomainResult = Result; 104 | 105 | impl From for DomainError { 106 | fn from(e: BookmarkBuilderError) -> Self { 107 | DomainError::BookmarkOperationFailed(e.to_string()) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /bkmr/src/domain/interpolation/errors.rs: -------------------------------------------------------------------------------- 1 | // src/domain/interpolation/errors.rs 2 | use thiserror::Error; 3 | 4 | #[derive(Error, Debug)] 5 | pub enum InterpolationError { 6 | #[error("Template syntax error: {0}")] 7 | Syntax(String), 8 | 9 | #[error("Template rendering error: {0}")] 10 | Rendering(String), 11 | 12 | #[error("Context error: {0}")] 13 | Context(String), 14 | 15 | #[error("Shell command error: {0}")] 16 | Shell(String), 17 | } 18 | 19 | // Add context method 20 | impl InterpolationError { 21 | pub fn context>(self, context: C) -> Self { 22 | match self { 23 | InterpolationError::Syntax(msg) => { 24 | InterpolationError::Syntax(format!("{}: {}", context.into(), msg)) 25 | } 26 | InterpolationError::Rendering(msg) => { 27 | InterpolationError::Rendering(format!("{}: {}", context.into(), msg)) 28 | } 29 | InterpolationError::Context(msg) => { 30 | InterpolationError::Context(format!("{}: {}", context.into(), msg)) 31 | } 32 | InterpolationError::Shell(msg) => { 33 | InterpolationError::Shell(format!("{}: {}", context.into(), msg)) 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /bkmr/src/domain/interpolation/interface.rs: -------------------------------------------------------------------------------- 1 | // src/domain/interpolation/interface.rs 2 | use crate::domain::bookmark::Bookmark; 3 | use crate::domain::interpolation::errors::InterpolationError; 4 | use std::sync::Arc; 5 | 6 | pub trait InterpolationEngine: Send + Sync + std::fmt::Debug { 7 | fn render_url(&self, url: &str) -> Result; 8 | fn render_bookmark_url(&self, bookmark: &Bookmark) -> Result; 9 | } 10 | 11 | pub trait ShellCommandExecutor: Send + Sync + std::fmt::Debug { 12 | fn execute(&self, command: &str) -> Result; 13 | fn arc_clone(&self) -> Arc; 14 | } 15 | -------------------------------------------------------------------------------- /bkmr/src/domain/interpolation/mod.rs: -------------------------------------------------------------------------------- 1 | // src/domain/interpolation/mod.rs 2 | pub mod errors; 3 | pub mod interface; 4 | -------------------------------------------------------------------------------- /bkmr/src/domain/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod action; 2 | pub mod action_resolver; 3 | pub mod bookmark; 4 | pub mod embedding; 5 | pub mod error; 6 | pub mod interpolation; 7 | pub mod repositories; 8 | pub mod search; 9 | pub mod services; 10 | pub mod system_tag; 11 | pub mod tag; 12 | -------------------------------------------------------------------------------- /bkmr/src/domain/repositories/import_repository.rs: -------------------------------------------------------------------------------- 1 | // src/domain/repositories/import_repository.rs 2 | use crate::domain::error::DomainResult; 3 | use crate::domain::tag::Tag; 4 | use std::collections::HashSet; 5 | use std::fmt::Debug; 6 | 7 | pub struct BookmarkImportData { 8 | pub url: String, 9 | pub title: String, 10 | pub content: String, 11 | pub tags: HashSet, 12 | } 13 | 14 | pub trait ImportRepository: Send + Sync + Debug { 15 | fn import_json_bookmarks(&self, path: &str) -> DomainResult>; 16 | fn import_text_documents(&self, path: &str) -> DomainResult>; 17 | } 18 | -------------------------------------------------------------------------------- /bkmr/src/domain/repositories/mod.rs: -------------------------------------------------------------------------------- 1 | // bkmr/src/domain/repositories/mod.rs 2 | pub mod import_repository; 3 | pub mod query; 4 | pub mod repository; 5 | -------------------------------------------------------------------------------- /bkmr/src/domain/repositories/repository.rs: -------------------------------------------------------------------------------- 1 | // src/domain/repositories/repository 2 | 3 | use crate::domain::bookmark::Bookmark; 4 | use crate::domain::error::DomainError; 5 | use crate::domain::repositories::query::{BookmarkQuery, SortDirection}; 6 | use crate::domain::tag::Tag; 7 | use crate::infrastructure::repositories::sqlite::connection::PooledConnection; 8 | use crate::infrastructure::repositories::sqlite::error::{SqliteRepositoryError, SqliteResult}; 9 | use std::collections::HashSet; 10 | /* 11 | Repository Interface 12 | The BookmarkRepository interface follows the repository pattern to separate domain models from data access: 13 | 14 | Domain-Centric: Methods speak in domain terms, not persistence terms 15 | Abstraction: Hides data access details behind a clean interface 16 | Testability: Easy to create mock implementations for testing 17 | Flexibility: Allows switching persistence mechanisms without changing domain code 18 | */ 19 | /// Repository trait for bookmark persistence operations 20 | pub trait BookmarkRepository: std::fmt::Debug + Send + Sync { 21 | /// Get a bookmark by its ID 22 | fn get_by_id(&self, id: i32) -> Result, DomainError>; 23 | 24 | /// Get a bookmark by its URL 25 | fn get_by_url(&self, url: &str) -> Result, DomainError>; 26 | 27 | /// Search for bookmarks using a query specification 28 | fn search(&self, query: &BookmarkQuery) -> Result, DomainError>; // Helper method to get all bookmark IDs 29 | 30 | /// Get all bookmarks 31 | fn get_all(&self) -> Result, DomainError>; 32 | 33 | /// Add a new bookmark 34 | fn add(&self, bookmark: &mut Bookmark) -> Result<(), DomainError>; 35 | 36 | /// Update an existing bookmark 37 | fn update(&self, bookmark: &Bookmark) -> Result<(), DomainError>; 38 | 39 | /// Delete a bookmark by ID 40 | fn delete(&self, id: i32) -> Result; 41 | 42 | /// Get all unique tags with their frequency 43 | fn get_all_tags(&self) -> Result, DomainError>; 44 | 45 | /// Get tags related to a specific tag (co-occurring) 46 | fn get_related_tags(&self, tag: &Tag) -> Result, DomainError>; 47 | 48 | /// Get random bookmarks 49 | fn get_random(&self, count: usize) -> Result, DomainError>; 50 | 51 | /// Get bookmarks without embeddings 52 | fn get_without_embeddings(&self) -> Result, DomainError>; 53 | 54 | /// Get bookmarks filtered by tags (all tags must match) 55 | fn get_by_all_tags(&self, tags: &HashSet) -> Result, DomainError>; 56 | 57 | /// Get bookmarks filtered by tags (any tag may match) 58 | fn get_by_any_tag(&self, tags: &HashSet) -> Result, DomainError>; 59 | 60 | /// Get bookmarks ordered by access date 61 | fn get_by_access_date( 62 | &self, 63 | direction: SortDirection, 64 | limit: Option, 65 | ) -> Result, DomainError>; 66 | 67 | /// Search bookmarks by text 68 | fn search_by_text(&self, text: &str) -> Result, DomainError>; 69 | 70 | fn get_bookmarks(&self, query: &str) -> SqliteResult>; 71 | 72 | fn get_bookmarks_fts(&self, fts_query: &str) -> SqliteResult>; 73 | 74 | /// Check if bookmark exists by URL 75 | fn exists_by_url(&self, url: &str) -> Result; 76 | 77 | /// Get bookmarks that are marked as embeddable but don't have embeddings yet 78 | fn get_embeddable_without_embeddings(&self) -> Result, DomainError>; 79 | fn get_bookmarks_by_ids(&self, ids: &[i32]) -> Result, DomainError>; 80 | fn get_all_bookmark_ids( 81 | &self, 82 | conn: &mut PooledConnection, 83 | ) -> Result, SqliteRepositoryError>; 84 | } 85 | -------------------------------------------------------------------------------- /bkmr/src/domain/services/clipboard.rs: -------------------------------------------------------------------------------- 1 | // src/domain/services/clipboard_service.rs 2 | use crate::domain::error::DomainResult; 3 | 4 | pub trait ClipboardService: Send + Sync + std::fmt::Debug { 5 | fn copy_to_clipboard(&self, text: &str) -> DomainResult<()>; 6 | } 7 | -------------------------------------------------------------------------------- /bkmr/src/domain/services/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod clipboard; 2 | -------------------------------------------------------------------------------- /bkmr/src/infrastructure/clipboard.rs: -------------------------------------------------------------------------------- 1 | // bkmr/src/infrastructure/clipboard.rs 2 | use crate::domain::error::{DomainError, DomainResult}; 3 | use crate::domain::services::clipboard::ClipboardService; 4 | use arboard::Clipboard; 5 | use tracing::instrument; 6 | // #[instrument(level = "debug")] 7 | // pub fn copy_to_clipboard(text: &str) -> DomainResult<()> { 8 | // let mut clipboard = Clipboard::new().context("Failed to initialize clipboard")?; 9 | // let clean_text = text.trim_end_matches('\n'); 10 | // clipboard 11 | // .set_text(clean_text) 12 | // .context("Failed to set clipboard text")?; 13 | // Ok(()) 14 | // } 15 | 16 | #[derive(Debug)] 17 | pub struct ClipboardServiceImpl; 18 | 19 | impl Default for ClipboardServiceImpl { 20 | fn default() -> Self { 21 | Self::new() 22 | } 23 | } 24 | 25 | impl ClipboardServiceImpl { 26 | pub fn new() -> Self { 27 | Self 28 | } 29 | } 30 | 31 | impl ClipboardService for ClipboardServiceImpl { 32 | #[instrument(level = "trace")] 33 | fn copy_to_clipboard(&self, text: &str) -> DomainResult<()> { 34 | match Clipboard::new() { 35 | Ok(mut clipboard) => { 36 | let clean_text = text.trim_end_matches('\n'); 37 | match clipboard.set_text(clean_text) { 38 | Ok(_) => Ok(()), 39 | Err(e) => Err(DomainError::Other(format!( 40 | "Failed to set clipboard text: {}", 41 | e 42 | ))), 43 | } 44 | } 45 | Err(e) => Err(DomainError::Other(format!( 46 | "Failed to initialize clipboard: {}", 47 | e 48 | ))), 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /bkmr/src/infrastructure/embeddings/dummy_provider.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::embedding::Embedder; 2 | use crate::domain::error::DomainResult; 3 | use std::any::Any; 4 | use tracing::{debug, instrument}; 5 | 6 | /// Dummy implementation that always returns None 7 | #[derive(Debug, Clone, Default)] 8 | pub struct DummyEmbedding; 9 | 10 | impl Embedder for DummyEmbedding { 11 | #[instrument] 12 | fn embed(&self, _text: &str) -> DomainResult>> { 13 | debug!("DummyEmbedding::embed() called - returns None"); 14 | Ok(None) 15 | } 16 | fn as_any(&self) -> &dyn Any { 17 | self 18 | } 19 | } 20 | 21 | #[cfg(test)] 22 | mod tests { 23 | use super::*; 24 | use serial_test::serial; 25 | 26 | #[test] 27 | #[serial] 28 | fn test_dummy_embedding_returns_none() { 29 | let dummy = DummyEmbedding; 30 | let result = dummy.embed("test text").unwrap(); 31 | assert!(result.is_none()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /bkmr/src/infrastructure/embeddings/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod dummy_provider; 2 | mod model; 3 | pub mod openai_provider; 4 | 5 | pub use dummy_provider::DummyEmbedding; 6 | pub use openai_provider::OpenAiEmbedding; 7 | -------------------------------------------------------------------------------- /bkmr/src/infrastructure/embeddings/model.rs: -------------------------------------------------------------------------------- 1 | use serde_derive::{Deserialize, Serialize}; 2 | 3 | #[derive(Serialize)] 4 | pub struct EmbeddingRequest { 5 | pub(crate) input: String, 6 | pub(crate) model: String, 7 | } 8 | 9 | #[derive(Deserialize)] 10 | pub struct EmbeddingResponse { 11 | pub(crate) data: Vec, 12 | } 13 | 14 | #[derive(Deserialize)] 15 | pub struct EmbeddingData { 16 | pub(crate) embedding: Vec, 17 | } 18 | -------------------------------------------------------------------------------- /bkmr/src/infrastructure/embeddings/openai_provider.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::embedding::Embedder; 2 | use crate::domain::error::{DomainError, DomainResult}; 3 | use crate::infrastructure::embeddings::model::{EmbeddingRequest, EmbeddingResponse}; 4 | use std::any::Any; 5 | use std::env; 6 | use tracing::{debug, instrument}; 7 | 8 | /// Implementation using OpenAI's embedding API 9 | #[derive(Debug, Clone)] 10 | pub struct OpenAiEmbedding { 11 | url: String, 12 | model: String, 13 | } 14 | 15 | impl Default for OpenAiEmbedding { 16 | fn default() -> Self { 17 | Self { 18 | url: "https://api.openai.com".to_string(), 19 | model: "text-embedding-ada-002".to_string(), 20 | } 21 | } 22 | } 23 | 24 | impl Embedder for OpenAiEmbedding { 25 | #[instrument] 26 | fn embed(&self, text: &str) -> DomainResult>> { 27 | debug!("OpenAI embedding request for text length: {}", text.len()); 28 | 29 | let api_key = env::var("OPENAI_API_KEY").map_err(|_| { 30 | DomainError::CannotFetchMetadata( 31 | "OPENAI_API_KEY environment variable not set".to_string(), 32 | ) 33 | })?; 34 | 35 | let client = reqwest::blocking::Client::new(); 36 | 37 | let request = EmbeddingRequest { 38 | input: text.to_string(), 39 | model: self.model.clone(), 40 | }; 41 | 42 | let response = client 43 | .post(format!("{}/v1/embeddings", self.url)) 44 | .header("Authorization", format!("Bearer {}", api_key)) 45 | .json(&request) 46 | .send() 47 | .map_err(|e| { 48 | DomainError::CannotFetchMetadata(format!("OpenAI API request failed: {}", e)) 49 | })?; 50 | 51 | if !response.status().is_success() { 52 | let error_text = response.text().map_err(|e| { 53 | DomainError::CannotFetchMetadata(format!("Failed to read error response: {}", e)) 54 | })?; 55 | 56 | return Err(DomainError::CannotFetchMetadata(format!( 57 | "OpenAI API returned error: {}", 58 | error_text 59 | ))); 60 | } 61 | 62 | let response_data: EmbeddingResponse = response.json().map_err(|e| { 63 | DomainError::CannotFetchMetadata(format!("Failed to parse OpenAI response: {}", e)) 64 | })?; 65 | 66 | if response_data.data.is_empty() { 67 | debug!("OpenAI API returned empty data array"); 68 | return Ok(None); 69 | } 70 | 71 | Ok(Some(response_data.data[0].embedding.clone())) 72 | } 73 | fn as_any(&self) -> &dyn Any { 74 | self 75 | } 76 | } 77 | 78 | impl OpenAiEmbedding { 79 | pub fn new(url: String, model: String) -> Self { 80 | Self { url, model } 81 | } 82 | } 83 | 84 | #[cfg(test)] 85 | mod tests { 86 | use super::*; 87 | use crate::util::testing::init_test_env; 88 | use serial_test::serial; 89 | 90 | #[test] 91 | #[serial] 92 | fn test_openai_embedding() { 93 | let _ = init_test_env(); 94 | if env::var("OPENAI_API_KEY").is_err() { 95 | // exit early if no API key is set 96 | eprintln!("OpenAI API_KEY environment variable not set"); 97 | return; 98 | } 99 | 100 | let openai = OpenAiEmbedding::default(); 101 | let result = openai.embed("test text"); 102 | assert!(result.is_ok()); 103 | assert_eq!(result.unwrap().unwrap().len(), 1536); 104 | } 105 | 106 | #[test] 107 | #[serial] 108 | fn test_openai_embedding_fails_without_api_key() { 109 | // Temporarily unset the API key if it exists 110 | let key_exists = env::var("OPENAI_API_KEY").is_ok(); 111 | let api_key_backup = if key_exists { 112 | Some(env::var("OPENAI_API_KEY").unwrap()) 113 | } else { 114 | None 115 | }; 116 | 117 | env::remove_var("OPENAI_API_KEY"); 118 | 119 | let openai = OpenAiEmbedding::default(); 120 | let result = openai.embed("test text"); 121 | assert!(result.is_err()); 122 | 123 | // Restore API key if it existed 124 | if let Some(key) = api_key_backup { 125 | env::set_var("OPENAI_API_KEY", key); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /bkmr/src/infrastructure/error.rs: -------------------------------------------------------------------------------- 1 | // src/infrastructure/error.rs 2 | use crate::domain::error::{DomainError, RepositoryError}; 3 | use thiserror::Error; 4 | 5 | #[derive(Error, Debug)] 6 | pub enum InfrastructureError { 7 | #[error("Database error: {0}")] 8 | Database(String), 9 | 10 | #[error("Network error: {0}")] 11 | Network(String), 12 | 13 | #[error("Serialization error: {0}")] 14 | Serialization(String), 15 | 16 | #[error("File system error: {0}")] 17 | FileSystem(String), 18 | 19 | #[error("Repository error: {0}")] 20 | Repository(#[from] RepositoryError), 21 | } 22 | 23 | // Add context method 24 | impl InfrastructureError { 25 | pub fn context>(self, context: C) -> Self { 26 | match self { 27 | InfrastructureError::Database(msg) => { 28 | InfrastructureError::Database(format!("{}: {}", context.into(), msg)) 29 | } 30 | InfrastructureError::Network(msg) => { 31 | InfrastructureError::Network(format!("{}: {}", context.into(), msg)) 32 | } 33 | InfrastructureError::Repository(err) => { 34 | InfrastructureError::Repository(err.context(context)) 35 | } 36 | err => InfrastructureError::Database(format!("{}: {}", context.into(), err)), 37 | } 38 | } 39 | } 40 | 41 | // Convert to domain errors 42 | impl From for DomainError { 43 | fn from(error: InfrastructureError) -> Self { 44 | match error { 45 | InfrastructureError::Database(msg) => { 46 | DomainError::RepositoryError(RepositoryError::Database(msg)) 47 | } 48 | InfrastructureError::Network(msg) => DomainError::CannotFetchMetadata(msg), 49 | InfrastructureError::Serialization(msg) => DomainError::SerializationError(msg), 50 | InfrastructureError::FileSystem(msg) => { 51 | DomainError::Io(std::io::Error::new(std::io::ErrorKind::Other, msg)) 52 | } 53 | InfrastructureError::Repository(err) => DomainError::RepositoryError(err), 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /bkmr/src/infrastructure/http.rs: -------------------------------------------------------------------------------- 1 | use crate::domain; 2 | use crate::domain::error::DomainResult; 3 | use std::time::{Duration, Instant}; 4 | use tracing::debug; 5 | 6 | /// Check if a website is accessible 7 | #[allow(dead_code)] 8 | pub fn check_website(url: &str, timeout_milliseconds: u64) -> (bool, u128) { 9 | let client = reqwest::blocking::Client::builder() 10 | .timeout(Duration::from_millis(timeout_milliseconds)) 11 | .build() 12 | .unwrap_or_else(|_| reqwest::blocking::Client::new()); // Fallback to default client in case of builder failure 13 | 14 | let start = Instant::now(); 15 | let response = client.head(url).send(); 16 | 17 | match response { 18 | Ok(resp) if resp.status().is_success() => { 19 | let duration = start.elapsed().as_millis(); 20 | (true, duration) 21 | } 22 | _ => (false, 0), // Return false and 0 duration in case of error or non-success status 23 | } 24 | } 25 | 26 | pub fn load_url_details(url: &str) -> DomainResult<(String, String, String)> { 27 | let client = reqwest::blocking::Client::new(); 28 | let body = client 29 | .get(url) 30 | .send() 31 | .map_err(|e| domain::error::DomainError::CannotFetchMetadata(e.to_string()))? 32 | .text() 33 | .map_err(|e| domain::error::DomainError::CannotFetchMetadata(e.to_string()))?; 34 | 35 | let document = select::document::Document::from(body.as_str()); 36 | 37 | let title = document 38 | .find(select::predicate::Name("title")) 39 | .next() 40 | .map(|n| n.text().trim().to_owned()) 41 | .unwrap_or_default(); 42 | 43 | let description = document 44 | .find(select::predicate::Attr("name", "description")) 45 | .next() 46 | .and_then(|n| n.attr("content")) 47 | .unwrap_or_default(); 48 | 49 | let keywords = document 50 | .find(select::predicate::Attr("name", "keywords")) 51 | .next() 52 | .and_then(|node| node.attr("content")) 53 | .unwrap_or_default(); 54 | 55 | debug!("Keywords {:?}", keywords); 56 | 57 | Ok((title, description.to_owned(), keywords.to_owned())) 58 | } 59 | 60 | #[cfg(test)] 61 | mod tests { 62 | use super::*; 63 | use crate::util::testing::{init_test_env, EnvGuard}; 64 | #[test] 65 | fn test_load_url_details() -> DomainResult<()> { 66 | let _ = init_test_env(); 67 | let _guard = EnvGuard::new(); 68 | 69 | let url = "http://example.com"; 70 | // let url = "https://www.rust-lang.org/"; 71 | let (title, description, keywords) = load_url_details(url)?; 72 | 73 | // Print values for debugging purposes 74 | println!("Title: {}", title); 75 | println!("Description: {}", description); 76 | println!("Keywords: {}", keywords); 77 | 78 | // Example.com returns "Example Domain" as title and typically no meta description or keywords. 79 | assert_eq!(title, "Example Domain"); 80 | assert_eq!(description, ""); 81 | assert_eq!(keywords, ""); 82 | Ok(()) 83 | } 84 | 85 | #[test] 86 | fn test_check_website() { 87 | // This test depends on network availability. 88 | let (accessible, duration) = check_website("http://example.com", 2000); 89 | assert!(accessible, "Expected example.com to be accessible"); 90 | assert!(duration > 0, "Duration should be greater than 0"); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /bkmr/src/infrastructure/interpolation/mod.rs: -------------------------------------------------------------------------------- 1 | // src/infrastructure/interpolation/mod.rs 2 | pub mod minijinja_engine; 3 | -------------------------------------------------------------------------------- /bkmr/src/infrastructure/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod clipboard; 2 | pub mod embeddings; 3 | pub mod error; 4 | pub(crate) mod http; 5 | pub mod interpolation; 6 | pub(crate) mod json; 7 | pub mod repositories; 8 | -------------------------------------------------------------------------------- /bkmr/src/infrastructure/repositories/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod json_import_repository; 2 | pub mod sqlite; 3 | -------------------------------------------------------------------------------- /bkmr/src/infrastructure/repositories/sqlite/connection.rs: -------------------------------------------------------------------------------- 1 | use super::error::{SqliteRepositoryError, SqliteResult}; 2 | use crate::app_state::AppState; 3 | use crate::infrastructure::repositories::sqlite::migration::MIGRATIONS; 4 | use chrono::Local; 5 | use diesel::r2d2::{self, ConnectionManager}; 6 | use diesel::sqlite::SqliteConnection; 7 | use diesel_migrations::MigrationHarness; 8 | use std::fs; 9 | use std::path::Path; 10 | use tracing::{debug, info, instrument}; 11 | 12 | pub type ConnectionPool = r2d2::Pool>; 13 | pub type PooledConnection = r2d2::PooledConnection>; 14 | 15 | /// Initialize a connection pool 16 | pub fn init_pool(database_url: &str) -> SqliteResult { 17 | debug!("Initializing connection pool for: {}", database_url); 18 | 19 | // Create parent directory if it doesn't exist 20 | if let Some(parent) = Path::new(database_url).parent() { 21 | if !parent.exists() { 22 | fs::create_dir_all(parent).map_err(SqliteRepositoryError::IoError)?; 23 | } 24 | } 25 | 26 | // Build the pool 27 | let manager = ConnectionManager::::new(database_url); 28 | let pool = r2d2::Pool::builder() 29 | .max_size(15) 30 | .build(manager) 31 | .map_err(|e| SqliteRepositoryError::ConnectionPoolError(e.to_string()))?; 32 | 33 | // Run migrations 34 | run_pending_migrations(&pool)?; 35 | 36 | info!("Connection pool initialized successfully"); 37 | Ok(pool) 38 | } 39 | 40 | /// Run any pending database migrations 41 | #[instrument(level = "info")] 42 | pub fn run_pending_migrations(pool: &ConnectionPool) -> SqliteResult<()> { 43 | let mut conn = pool 44 | .get() 45 | .map_err(|e| SqliteRepositoryError::ConnectionPoolError(e.to_string()))?; 46 | 47 | // Check if there are pending migrations before prompting 48 | let pending = conn.pending_migrations(MIGRATIONS).map_err(|e| { 49 | SqliteRepositoryError::MigrationError(format!("Failed to check pending migrations: {}", e)) 50 | })?; 51 | 52 | if pending.is_empty() { 53 | debug!("No pending migrations to run"); 54 | return Ok(()); 55 | } 56 | 57 | // Display pending migrations 58 | eprintln!("This version requires DB schema migration:"); 59 | for migration in &pending { 60 | eprintln!(" - {}", migration.name()); 61 | } 62 | 63 | // Get the database path from the pool connection 64 | let app_state = AppState::read_global(); 65 | let db_path = &app_state.settings.db_url; 66 | let db_path = Path::new(db_path); 67 | 68 | // Only create a backup if the database file already exists 69 | if db_path.exists() { 70 | // Create backup with date suffix 71 | let date_suffix = Local::now().format("%Y%m%d").to_string(); 72 | 73 | if let Some(file_name) = db_path.file_name() { 74 | let file_name_str = file_name.to_string_lossy(); 75 | let backup_name = if let Some(ext_pos) = file_name_str.rfind('.') { 76 | let (name, ext) = file_name_str.split_at(ext_pos); 77 | format!("{}_backup_{}{}", name, date_suffix, ext) 78 | } else { 79 | format!("{}_backup_{}", file_name_str, date_suffix) 80 | }; 81 | 82 | let backup_path = db_path.with_file_name(backup_name); 83 | 84 | // Copy the database file and fail if backup creation fails 85 | fs::copy(db_path, &backup_path).map_err(|e| { 86 | SqliteRepositoryError::IoError(std::io::Error::new( 87 | std::io::ErrorKind::Other, 88 | format!("Failed to create backup: {}", e), 89 | )) 90 | })?; 91 | 92 | eprintln!("Backup created at: {}", backup_path.display()); 93 | } else { 94 | return Err(SqliteRepositoryError::OperationFailed( 95 | "Could not determine database filename for backup".to_string(), 96 | )); 97 | } 98 | } else { 99 | debug!("No existing database to backup before migrations"); 100 | } 101 | 102 | // Run the migrations 103 | conn.run_pending_migrations(MIGRATIONS).map_err(|e| { 104 | SqliteRepositoryError::MigrationError(format!("Failed to run migrations: {}", e)) 105 | })?; 106 | 107 | eprintln!("Migrations completed successfully."); 108 | Ok(()) 109 | } 110 | -------------------------------------------------------------------------------- /bkmr/src/infrastructure/repositories/sqlite/error.rs: -------------------------------------------------------------------------------- 1 | // src/infrastructure/repositories/sqlite/error.rs 2 | use crate::domain::error::{DomainError, RepositoryError}; 3 | use diesel::r2d2; 4 | use diesel::result::Error as DieselError; 5 | use thiserror::Error; 6 | 7 | #[derive(Error, Debug)] 8 | pub enum SqliteRepositoryError { 9 | #[error("Database error: {0}")] 10 | DatabaseError(#[from] DieselError), 11 | 12 | #[error("Diesel connection error: {0}")] 13 | ConnectionError(#[from] diesel::ConnectionError), 14 | 15 | #[error("Connection pool error: {0}")] 16 | ConnectionPoolError(String), 17 | 18 | #[error("Bookmark not found with ID: {0}")] 19 | BookmarkNotFound(i32), 20 | 21 | #[error("Failed to convert entity: {0}")] 22 | ConversionError(String), 23 | 24 | #[error("Invalid parameter: {0}")] 25 | InvalidParameter(String), 26 | 27 | #[error("IO error: {0}")] 28 | IoError(#[from] std::io::Error), 29 | 30 | #[error("Migration error: {0}")] 31 | MigrationError(String), 32 | 33 | #[error("{0}")] 34 | OperationFailed(String), 35 | } 36 | 37 | pub type SqliteResult = Result; 38 | 39 | // Add a context method to SqliteRepositoryError 40 | impl SqliteRepositoryError { 41 | pub fn context>(self, context: C) -> Self { 42 | match self { 43 | SqliteRepositoryError::OperationFailed(msg) => { 44 | SqliteRepositoryError::OperationFailed(format!("{}: {}", context.into(), msg)) 45 | } 46 | SqliteRepositoryError::ConversionError(msg) => { 47 | SqliteRepositoryError::ConversionError(format!("{}: {}", context.into(), msg)) 48 | } 49 | err => SqliteRepositoryError::OperationFailed(format!("{}: {}", context.into(), err)), 50 | } 51 | } 52 | } 53 | 54 | impl From for SqliteRepositoryError { 55 | fn from(err: r2d2::Error) -> Self { 56 | SqliteRepositoryError::ConnectionPoolError(err.to_string()) 57 | } 58 | } 59 | 60 | // Convert SQLite errors to domain RepositoryError 61 | impl From for RepositoryError { 62 | fn from(err: SqliteRepositoryError) -> Self { 63 | match err { 64 | SqliteRepositoryError::BookmarkNotFound(id) => { 65 | RepositoryError::NotFound(format!("Bookmark with ID {}", id)) 66 | } 67 | SqliteRepositoryError::DatabaseError(diesel_err) => match diesel_err { 68 | DieselError::NotFound => { 69 | RepositoryError::NotFound("Resource not found".to_string()) 70 | } 71 | DieselError::DatabaseError(_, info) => { 72 | RepositoryError::Database(format!("Database error: {}", info.message())) 73 | } 74 | _ => RepositoryError::Database(format!("Database error: {}", diesel_err)), 75 | }, 76 | SqliteRepositoryError::ConnectionError(e) => { 77 | RepositoryError::Connection(format!("Database connection error: {}", e)) 78 | } 79 | SqliteRepositoryError::ConnectionPoolError(e) => { 80 | RepositoryError::Connection(format!("Connection pool error: {}", e)) 81 | } 82 | SqliteRepositoryError::ConversionError(e) => { 83 | RepositoryError::Other(format!("Data conversion error: {}", e)) 84 | } 85 | SqliteRepositoryError::InvalidParameter(e) => { 86 | RepositoryError::Other(format!("Invalid parameter: {}", e)) 87 | } 88 | SqliteRepositoryError::IoError(e) => RepositoryError::Other(format!("IO error: {}", e)), 89 | SqliteRepositoryError::MigrationError(e) => { 90 | RepositoryError::Other(format!("Migration error: {}", e)) 91 | } 92 | SqliteRepositoryError::OperationFailed(e) => RepositoryError::Other(e), 93 | } 94 | } 95 | } 96 | 97 | // Simplified conversion from SqliteRepositoryError to DomainError 98 | impl From for DomainError { 99 | fn from(err: SqliteRepositoryError) -> Self { 100 | DomainError::RepositoryError(err.into()) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /bkmr/src/infrastructure/repositories/sqlite/migration.rs: -------------------------------------------------------------------------------- 1 | use diesel::{RunQueryDsl, SqliteConnection}; 2 | // src/infrastructure/repositories/sqlite/migration.rs 3 | use crate::infrastructure::repositories::sqlite::error::SqliteRepositoryError; 4 | use diesel::sqlite::Sqlite; 5 | use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; 6 | use tracing::{debug, instrument}; 7 | 8 | pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations"); 9 | 10 | /// Initializes the database by running all pending migrations. 11 | /// 12 | /// This function takes a mutable reference to a `MigrationHarness` for a SQLite database. 13 | /// It first reverts all migrations using the `revert_all_migrations` method. 14 | /// Then, it retrieves all pending migrations and logs their names. 15 | /// Finally, it runs all pending migrations using the `run_pending_migrations` method. 16 | /// 17 | /// # Errors 18 | /// 19 | /// This function will return an error if any of the following operations fail: 20 | /// 21 | /// * Reverting all migrations 22 | /// * Retrieving pending migrations 23 | /// * Running pending migrations 24 | #[allow(unused)] 25 | pub fn init_db( 26 | connection: &mut impl MigrationHarness, 27 | ) -> Result<(), SqliteRepositoryError> { 28 | debug!("{:?}", "--> initdb <--"); 29 | 30 | connection.revert_all_migrations(MIGRATIONS).map_err(|e| { 31 | SqliteRepositoryError::MigrationError(format!("Failed to revert migrations: {}", e)) 32 | })?; 33 | 34 | let pending = connection.pending_migrations(MIGRATIONS).map_err(|e| { 35 | SqliteRepositoryError::MigrationError(format!("Failed to get pending migrations: {}", e)) 36 | })?; 37 | 38 | pending.iter().for_each(|m| { 39 | debug!("Pending Migration: {}", m.name()); 40 | }); 41 | 42 | connection.run_pending_migrations(MIGRATIONS).map_err(|e| { 43 | SqliteRepositoryError::MigrationError(format!("Failed to run pending migrations: {}", e)) 44 | })?; 45 | 46 | Ok(()) 47 | } 48 | 49 | /// Checks if the schema migrations table exists 50 | #[instrument(skip(conn), level = "debug")] 51 | pub fn check_schema_migrations_exists( 52 | conn: &mut SqliteConnection, 53 | ) -> Result { 54 | use diesel::sql_query; 55 | use diesel::sql_types::Integer; 56 | use diesel::QueryableByName; 57 | 58 | #[derive(QueryableByName, Debug)] 59 | struct TableCheckResult { 60 | #[diesel(sql_type = Integer)] 61 | pub table_exists: i32, 62 | } 63 | 64 | let query = " 65 | SELECT COUNT(*) as table_exists 66 | FROM sqlite_master 67 | WHERE type='table' AND name='__diesel_schema_migrations' 68 | "; 69 | 70 | let result: TableCheckResult = sql_query(query) 71 | .get_result(conn) 72 | .map_err(SqliteRepositoryError::DatabaseError)?; 73 | 74 | Ok(result.table_exists > 0) 75 | } 76 | -------------------------------------------------------------------------------- /bkmr/src/infrastructure/repositories/sqlite/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod connection; 2 | pub(crate) mod error; 3 | pub mod migration; 4 | pub mod model; 5 | pub mod repository; 6 | pub mod schema; 7 | -------------------------------------------------------------------------------- /bkmr/src/infrastructure/repositories/sqlite/schema.rs: -------------------------------------------------------------------------------- 1 | // @generated automatically by Diesel CLI. 2 | 3 | diesel::table! { 4 | bookmarks (id) { 5 | id -> Integer, 6 | URL -> Text, 7 | metadata -> Text, 8 | tags -> Text, 9 | desc -> Text, 10 | flags -> Integer, 11 | last_update_ts -> Timestamp, 12 | embedding -> Nullable, 13 | content_hash -> Nullable, 14 | created_ts -> Nullable, 15 | embeddable -> Bool, 16 | } 17 | } 18 | 19 | diesel::table! { 20 | bookmarks_fts (id) { 21 | id -> Integer, 22 | URL -> Text, 23 | metadata -> Text, 24 | tags -> Text, 25 | desc -> Text, 26 | flags -> Integer, 27 | last_update_ts -> Timestamp, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /bkmr/src/lib.rs: -------------------------------------------------------------------------------- 1 | // src/lib.rs 2 | #![crate_type = "lib"] 3 | #![crate_name = "bkmr"] 4 | 5 | extern crate skim; 6 | 7 | // Core modules 8 | pub mod application; 9 | pub mod domain; 10 | pub mod infrastructure; 11 | 12 | // CLI modules 13 | pub mod app_state; 14 | pub mod cli; 15 | pub mod config; 16 | pub mod util; 17 | 18 | #[cfg(test)] 19 | mod tests {} 20 | -------------------------------------------------------------------------------- /bkmr/src/main.rs: -------------------------------------------------------------------------------- 1 | // src/main.rs 2 | use bkmr::infrastructure::embeddings::{DummyEmbedding, OpenAiEmbedding}; 3 | 4 | use bkmr::app_state::AppState; 5 | use bkmr::cli::args::Cli; 6 | use bkmr::cli::execute_command; 7 | use bkmr::domain::embedding::Embedder; 8 | use clap::Parser; 9 | use crossterm::style::Stylize; 10 | use std::sync::Arc; 11 | use termcolor::{ColorChoice, StandardStream}; 12 | use tracing::{debug, info, instrument}; 13 | use tracing_subscriber::{ 14 | filter::{filter_fn, LevelFilter}, 15 | fmt::{self, format::FmtSpan}, 16 | prelude::*, 17 | }; 18 | 19 | #[instrument] 20 | fn main() { 21 | // use stderr as human output in order to make stdout output passable to downstream processes 22 | let stderr = StandardStream::stderr(ColorChoice::Always); 23 | let cli = Cli::parse(); 24 | setup_logging(cli.debug); 25 | 26 | // Create embedder based on CLI option 27 | let embedder: Arc = if cli.openai { 28 | debug!("OpenAI embeddings enabled"); 29 | Arc::new(OpenAiEmbedding::default()) 30 | } else { 31 | debug!("Using DummyEmbedding (no embeddings will be stored)"); 32 | Arc::new(DummyEmbedding) 33 | }; 34 | 35 | // Convert config_file to Path reference if provided 36 | let config_path_ref = cli.config_file.as_deref(); 37 | 38 | // Initialize AppState with the embedder and config file 39 | let app_state = AppState::new_with_config_file(embedder, config_path_ref); 40 | let result = AppState::update_global(app_state); 41 | 42 | if let Err(e) = result { 43 | eprintln!("{}: {}", "Failed to initialize AppState".red(), e); 44 | std::process::exit(1); 45 | } 46 | 47 | // Execute the command 48 | if let Err(e) = execute_command(stderr, cli) { 49 | eprintln!("{}", format!("Error: {}", e).red()); 50 | std::process::exit(1); 51 | } 52 | } 53 | 54 | fn setup_logging(verbosity: u8) { 55 | debug!("INIT: Attempting logger init from main.rs"); 56 | 57 | let filter = match verbosity { 58 | 0 => LevelFilter::WARN, 59 | 1 => LevelFilter::INFO, 60 | 2 => LevelFilter::DEBUG, 61 | 3 => LevelFilter::TRACE, 62 | _ => { 63 | eprintln!("Don't be crazy, max is -d -d -d"); 64 | LevelFilter::TRACE 65 | } 66 | }; 67 | 68 | // Create a noisy module filter 69 | let noisy_modules = [ 70 | "skim", 71 | "html5ever", 72 | "reqwest", 73 | "mio", 74 | "want", 75 | "tuikit", 76 | "hyper_util", 77 | ]; 78 | let module_filter = filter_fn(move |metadata| { 79 | !noisy_modules 80 | .iter() 81 | .any(|name| metadata.target().starts_with(name)) 82 | }); 83 | 84 | // Create a subscriber with formatted output directed to stderr 85 | let fmt_layer = fmt::layer() 86 | .with_writer(std::io::stderr) // Set writer first 87 | .with_target(true) 88 | // src/main.rs (continued) 89 | .with_thread_names(false) 90 | .with_span_events(FmtSpan::ENTER) 91 | .with_span_events(FmtSpan::CLOSE); 92 | 93 | // Apply filters to the layer 94 | let filtered_layer = fmt_layer.with_filter(filter).with_filter(module_filter); 95 | 96 | tracing_subscriber::registry().with(filtered_layer).init(); 97 | 98 | // Log initial debug level 99 | match filter { 100 | LevelFilter::INFO => info!("Debug mode: info"), 101 | LevelFilter::DEBUG => debug!("Debug mode: debug"), 102 | LevelFilter::TRACE => debug!("Debug mode: trace"), 103 | _ => {} 104 | } 105 | } 106 | 107 | #[cfg(test)] 108 | mod tests { 109 | use super::*; 110 | 111 | #[test] 112 | fn verify_cli() { 113 | use clap::CommandFactory; 114 | Cli::command().debug_assert() 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /bkmr/src/util/helper.rs: -------------------------------------------------------------------------------- 1 | // src/util/helper.rs 2 | use md5; 3 | use std::io::{self, IsTerminal, Write}; 4 | 5 | /// Ensure a vector of strings contains only integers 6 | pub fn ensure_int_vector(vec: &[String]) -> Option> { 7 | vec.iter() 8 | .map(|s| s.parse::()) 9 | .collect::, _>>() 10 | .map(|mut v| { 11 | v.sort(); 12 | v 13 | }) 14 | .ok() 15 | } 16 | 17 | /// Calculate MD5 hash of content 18 | pub fn calc_content_hash(content: &str) -> Vec { 19 | md5::compute(content).0.to_vec() 20 | } 21 | 22 | /// Interactive confirmation prompt 23 | pub fn confirm(prompt: &str) -> bool { 24 | print!("{} (y/N): ", prompt); 25 | io::stdout().flush().unwrap(); // Ensure the prompt is displayed immediately 26 | 27 | let mut user_input = String::new(); 28 | io::stdin() 29 | .read_line(&mut user_input) 30 | .expect("Failed to read line"); 31 | 32 | matches!(user_input.trim().to_lowercase().as_str(), "y" | "yes") 33 | } 34 | 35 | pub fn is_stdout_piped() -> bool { 36 | !io::stdout().is_terminal() 37 | } 38 | 39 | pub fn is_stderr_piped() -> bool { 40 | !io::stderr().is_terminal() 41 | } 42 | 43 | #[cfg(test)] 44 | mod tests { 45 | use super::*; 46 | 47 | #[test] 48 | fn test_ensure_int_vector_valid() { 49 | let input = vec!["3".to_string(), "1".to_string(), "2".to_string()]; 50 | let result = ensure_int_vector(&input); 51 | // Expected vector is sorted in ascending order. 52 | assert_eq!(result, Some(vec![1, 2, 3])); 53 | } 54 | 55 | #[test] 56 | fn test_ensure_int_vector_invalid() { 57 | let input = vec!["3".to_string(), "abc".to_string(), "2".to_string()]; 58 | let result = ensure_int_vector(&input); 59 | assert!(result.is_none()); 60 | } 61 | 62 | #[test] 63 | fn test_calc_content_hash() { 64 | let content = "hello world"; 65 | let hash = calc_content_hash(content); 66 | // Using md5 directly to get the expected hash. 67 | let expected = md5::compute(content); 68 | assert_eq!(hash, expected.0.to_vec()); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /bkmr/src/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod helper; 2 | pub mod path; 3 | pub mod testing; 4 | -------------------------------------------------------------------------------- /bkmr/tests/application/mod.rs: -------------------------------------------------------------------------------- 1 | mod services; 2 | -------------------------------------------------------------------------------- /bkmr/tests/application/services/mod.rs: -------------------------------------------------------------------------------- 1 | mod test_bookmark_service_impl_load_json_bookmarks; 2 | mod test_bookmark_service_impl_search; 3 | -------------------------------------------------------------------------------- /bkmr/tests/application/services/test_bookmark_service_impl_load_json_bookmarks.rs: -------------------------------------------------------------------------------- 1 | use bkmr::app_state::AppState; 2 | use bkmr::application::error::ApplicationResult; 3 | use bkmr::application::services::factory; 4 | use bkmr::domain::repositories::repository::BookmarkRepository; 5 | use bkmr::infrastructure::repositories::sqlite::repository::SqliteBookmarkRepository; 6 | use bkmr::util::testing::{init_test_env, EnvGuard}; 7 | use serial_test::serial; 8 | use std::path::Path; 9 | 10 | #[test] 11 | #[serial] 12 | fn given_valid_ndjson_file_when_load_json_bookmarks_then_adds_bookmarks_to_database( 13 | ) -> ApplicationResult<()> { 14 | // Arrange 15 | let _ = init_test_env(); 16 | let _guard = EnvGuard::new(); 17 | 18 | // Clean database for the test 19 | let app_state = AppState::read_global(); 20 | let repository = SqliteBookmarkRepository::from_url(&app_state.settings.db_url) 21 | .expect("Could not load bookmarks database"); 22 | repository 23 | .empty_bookmark_table() 24 | .expect("Failed to empty bookmark table"); 25 | 26 | // Create service with real dependencies 27 | let bookmark_service = factory::create_bookmark_service(); 28 | 29 | // Path to test file 30 | let test_file_path = Path::new("tests/resources/bookmarks.ndjson") 31 | .to_str() 32 | .unwrap() 33 | .to_string(); 34 | 35 | // Act 36 | let processed_count = bookmark_service.load_json_bookmarks(&test_file_path, false)?; 37 | 38 | // Assert 39 | // Verify that the correct number of bookmarks were processed 40 | assert_eq!( 41 | processed_count, 2, 42 | "Should have processed 2 bookmarks from the file" 43 | ); 44 | 45 | // Get all bookmarks from the database 46 | let bookmarks = repository.get_all()?; 47 | assert_eq!(bookmarks.len(), 2, "Database should contain 2 bookmarks"); 48 | 49 | // Verify the first bookmark was added correctly 50 | let first_bookmark = bookmarks 51 | .iter() 52 | .find(|b| b.title == "linear_programming.md") 53 | .expect("First bookmark not found"); 54 | 55 | assert_eq!(first_bookmark.url, "$VIMWIKI_PATH/linear_programming.md:0"); 56 | assert!( 57 | first_bookmark 58 | .tags 59 | .iter() 60 | .any(|t| t.value() == "_imported_"), 61 | "First bookmark should have the '_imported_' tag" 62 | ); 63 | 64 | // Verify the second bookmark was added correctly 65 | let second_bookmark = bookmarks 66 | .iter() 67 | .find(|b| b.title == "personal_intro.md") 68 | .expect("Second bookmark not found"); 69 | 70 | assert_eq!(second_bookmark.url, "$VIMWIKI_PATH/personal_intro.md:0"); 71 | assert!( 72 | second_bookmark 73 | .tags 74 | .iter() 75 | .any(|t| t.value() == "_imported_"), 76 | "Second bookmark should have the '_imported_' tag" 77 | ); 78 | 79 | Ok(()) 80 | } 81 | -------------------------------------------------------------------------------- /bkmr/tests/cli/mod.rs: -------------------------------------------------------------------------------- 1 | mod test_bookmark_commands; 2 | mod test_search; 3 | -------------------------------------------------------------------------------- /bkmr/tests/cli/test_bookmark_commands.rs: -------------------------------------------------------------------------------- 1 | use bkmr::cli::args::{Cli, Commands}; 2 | use bkmr::cli::bookmark_commands::{apply_prefix_tags, parse_tag_string}; 3 | use bkmr::domain::tag::Tag; 4 | use bkmr::util::testing::{init_test_env, EnvGuard}; 5 | use serial_test::serial; 6 | use termcolor::{ColorChoice, StandardStream}; 7 | 8 | // fn create_mock_service() -> impl BookmarkService { 9 | // // Create a real repository but in a test environment 10 | // let repository = setup_test_db(); 11 | // let repository_arc = Arc::new(repository); 12 | // let embedder = Arc::new(DummyEmbedding); 13 | // BookmarkServiceImpl::new( 14 | // repository_arc, 15 | // embedder, 16 | // Arc::new(JsonImportRepository::new()), 17 | // ) 18 | // } 19 | 20 | #[test] 21 | #[serial] 22 | fn given_tag_prefix_options_when_search_then_combines_tag_sets() { 23 | // Arrange 24 | let _env = init_test_env(); 25 | let _guard = EnvGuard::new(); 26 | 27 | // Create a sample CLI command with tag prefixes 28 | let _ = Cli { 29 | name: None, 30 | config: None, 31 | config_file: None, 32 | debug: 0, 33 | openai: false, 34 | generate_config: false, 35 | command: Some(Commands::Search { 36 | fts_query: None, 37 | tags_exact: Some("tag1".to_string()), 38 | tags_exact_prefix: Some("prefix1".to_string()), 39 | tags_all: Some("tag2".to_string()), 40 | tags_all_prefix: Some("prefix2".to_string()), 41 | tags_all_not: Some("tag3".to_string()), 42 | tags_all_not_prefix: Some("prefix3".to_string()), 43 | tags_any: Some("tag4".to_string()), 44 | tags_any_prefix: Some("prefix4".to_string()), 45 | tags_any_not: Some("tag5".to_string()), 46 | tags_any_not_prefix: Some("prefix5".to_string()), 47 | order_desc: false, 48 | order_asc: false, 49 | non_interactive: true, 50 | is_fuzzy: false, 51 | fzf_style: None, 52 | is_json: true, // Use JSON output for easier testing 53 | limit: None, 54 | }), 55 | }; 56 | 57 | // Use a null output stream for testing 58 | let _ = StandardStream::stderr(ColorChoice::Never); 59 | 60 | // We'll mock the service function calls by patching it with a function that records calls 61 | // For simplicity in this example, we'll just verify the core functions work as expected 62 | 63 | // Verify the tag string parsing 64 | let exact_tags = apply_prefix_tags( 65 | parse_tag_string(&Some("tag1".to_string())), 66 | parse_tag_string(&Some("prefix1".to_string())), 67 | ); 68 | 69 | // Assert 70 | assert!(exact_tags.is_some()); 71 | let exact_tags_set = exact_tags.unwrap(); 72 | assert_eq!(exact_tags_set.len(), 2); 73 | assert!(exact_tags_set.contains(&Tag::new("tag1").unwrap())); 74 | assert!(exact_tags_set.contains(&Tag::new("prefix1").unwrap())); 75 | } 76 | 77 | #[test] 78 | #[serial] 79 | fn given_search_command_with_prefixes_when_executed_then_performs_search() { 80 | // This test would need to mock the BookmarkService to verify the right parameters 81 | // are passed through. A full implementation would be fairly complex. 82 | // For simplicity, I'll show the test structure without implementing the mocking: 83 | 84 | // Arrange 85 | let _env = init_test_env(); 86 | let _guard = EnvGuard::new(); 87 | 88 | // Mock the service to record calls and return a predefined result 89 | // This would require a more complex test setup with a trait object and mock implementation 90 | 91 | // Create a CLI command with some tag prefixes 92 | let _ = Cli { 93 | name: None, 94 | config: None, 95 | config_file: None, 96 | debug: 0, 97 | openai: false, 98 | generate_config: false, 99 | command: Some(Commands::Search { 100 | fts_query: None, 101 | tags_exact: Some("tag1".to_string()), 102 | tags_exact_prefix: Some("prefix1".to_string()), 103 | tags_all: None, 104 | tags_all_prefix: None, 105 | tags_all_not: None, 106 | tags_all_not_prefix: None, 107 | tags_any: None, 108 | tags_any_prefix: None, 109 | tags_any_not: None, 110 | tags_any_not_prefix: None, 111 | order_desc: false, 112 | order_asc: false, 113 | non_interactive: true, 114 | is_fuzzy: false, 115 | fzf_style: None, 116 | is_json: true, 117 | limit: None, 118 | }), 119 | }; 120 | 121 | // Use a null output stream for testing 122 | let _ = StandardStream::stderr(ColorChoice::Never); 123 | 124 | // todo: complete the test 125 | // Act 126 | // In a real test, we would use dependency injection to verify the service is called correctly 127 | // search(stderr, cli); 128 | 129 | // Assert 130 | // Verify the search_bookmarks method was called with exact_tags containing both tag1 and prefix1 131 | } 132 | -------------------------------------------------------------------------------- /bkmr/tests/cli/test_search.rs: -------------------------------------------------------------------------------- 1 | use assert_cmd::Command; 2 | use serial_test::serial; 3 | use bkmr::util::testing::{init_test_env, EnvGuard}; 4 | 5 | #[test] 6 | #[serial] 7 | fn test_search_command_with_tags() { 8 | // Setup test environment with the example database 9 | let _env = init_test_env(); 10 | let _guard = EnvGuard::new(); 11 | 12 | let mut cmd = Command::cargo_bin("bkmr").expect("Failed to create command"); 13 | 14 | // Execute the search command with tag filtering 15 | let result = cmd 16 | .arg("search") 17 | .arg("--tags") 18 | .arg("aaa") 19 | .arg("--np") // non-interactive mode 20 | .arg("--limit") // non-interactive mode 21 | .arg("4") // non-interactive mode 22 | .assert() 23 | .success(); 24 | 25 | // Verify the output contains expected bookmarks with tag "aaa" 26 | let output = result.get_output(); 27 | let stdout = String::from_utf8_lossy(&output.stdout); 28 | let stderr = String::from_utf8_lossy(&output.stderr); 29 | 30 | // The example database has bookmarks with "aaa" tag 31 | assert!(stderr.contains("bookmarks"), "Should show number of bookmarks found"); 32 | 33 | // In non-interactive mode, the output should contain bookmark IDs 34 | let lines: Vec<&str> = stdout.lines().collect(); 35 | assert!(!lines.is_empty(), "Should return at least one bookmark ID"); 36 | 37 | assert_eq!(lines[0], "3,4,5,6", "Should return bookmark IDs: 3,4,5,6"); 38 | } -------------------------------------------------------------------------------- /bkmr/tests/infrastructure/interpolation/minijinja_engine_test.rs: -------------------------------------------------------------------------------- 1 | use bkmr::domain::bookmark::{Bookmark, BookmarkBuilder}; 2 | use bkmr::domain::interpolation::errors::InterpolationError; 3 | use bkmr::domain::interpolation::interface::{InterpolationEngine, ShellCommandExecutor}; 4 | use bkmr::domain::tag::Tag; 5 | use bkmr::infrastructure::interpolation::minijinja_engine::MiniJinjaEngine; 6 | use bkmr::util::testing::init_test_env; 7 | use chrono::{Datelike, Utc}; 8 | use serial_test::serial; 9 | use std::collections::{HashMap, HashSet}; 10 | use std::sync::Arc; 11 | 12 | // Mock shell executor for testing 13 | #[derive(Clone, Debug)] 14 | struct MockShellExecutor { 15 | responses: HashMap, 16 | } 17 | 18 | impl MockShellExecutor { 19 | fn new() -> Self { 20 | let mut responses = HashMap::new(); 21 | responses.insert("date".to_string(), "2023-01-01".to_string()); 22 | responses.insert("whoami".to_string(), "testuser".to_string()); 23 | Self { responses } 24 | } 25 | } 26 | 27 | impl ShellCommandExecutor for MockShellExecutor { 28 | fn execute(&self, cmd: &str) -> Result { 29 | match self.responses.get(cmd) { 30 | Some(response) => Ok(response.clone()), 31 | None => Err(InterpolationError::Shell(format!( 32 | "Unknown command: {}", 33 | cmd 34 | ))), 35 | } 36 | } 37 | 38 | fn arc_clone(&self) -> Arc { 39 | Arc::new(self.clone()) 40 | } 41 | } 42 | 43 | fn create_test_bookmark() -> Bookmark { 44 | let mut tags = HashSet::new(); 45 | tags.insert(Tag::new("test").unwrap()); 46 | tags.insert(Tag::new("example").unwrap()); 47 | 48 | BookmarkBuilder::default() 49 | .id(Some(42)) 50 | .url("https://example.com/{{ env_USER }}/{{ title | lower }}") 51 | .title("Test Bookmark") 52 | .description("A test bookmark") 53 | .tags(tags) 54 | .access_count(5) 55 | .created_at(Utc::now()) 56 | .updated_at(Utc::now()) 57 | .embedding(None) 58 | .content_hash(None) 59 | .embeddable(false) 60 | .build() 61 | .unwrap() 62 | } 63 | 64 | #[test] 65 | #[serial] 66 | fn test_render_static_url() { 67 | let _test_env = init_test_env(); 68 | let engine = MiniJinjaEngine::new(Arc::new(MockShellExecutor::new())); 69 | let url = "https://example.com"; 70 | 71 | let result = engine.render_url(url); 72 | assert!(result.is_ok()); 73 | assert_eq!(result.unwrap(), url); 74 | } 75 | 76 | #[test] 77 | #[serial] 78 | fn test_render_template_url() { 79 | let _test_env = init_test_env(); 80 | std::env::set_var("USER", "testuser"); 81 | 82 | let engine = MiniJinjaEngine::new(Arc::new(MockShellExecutor::new())); 83 | let url = "https://example.com/{{ env_USER }}/profile"; 84 | 85 | let result = engine.render_url(url); 86 | assert!(result.is_ok()); 87 | assert_eq!(result.unwrap(), "https://example.com/testuser/profile"); 88 | } 89 | 90 | #[test] 91 | #[serial] 92 | fn test_render_bookmark_url() { 93 | let _test_env = init_test_env(); 94 | std::env::set_var("USER", "testuser"); 95 | 96 | let engine = MiniJinjaEngine::new(Arc::new(MockShellExecutor::new())); 97 | let bookmark = create_test_bookmark(); 98 | 99 | let result = engine.render_bookmark_url(&bookmark); 100 | assert!(result.is_ok()); 101 | assert_eq!( 102 | result.unwrap(), 103 | "https://example.com/testuser/test bookmark" 104 | ); 105 | } 106 | 107 | #[test] 108 | #[serial] 109 | fn test_date_filters() { 110 | let _test_env = init_test_env(); 111 | let engine = MiniJinjaEngine::new(Arc::new(MockShellExecutor::new())); 112 | 113 | // Use current_date which is added to context 114 | let url = "https://example.com/{{ current_date | strftime('%Y-%m') }}"; 115 | let today = Utc::now(); 116 | let expected = format!("https://example.com/{}-{:02}", today.year(), today.month()); 117 | 118 | let result = engine.render_url(url); 119 | assert!(result.is_ok()); 120 | assert_eq!(result.unwrap(), expected); 121 | } 122 | 123 | #[test] 124 | #[serial] 125 | fn test_shell_filter() { 126 | let _test_env = init_test_env(); 127 | let engine = MiniJinjaEngine::new(Arc::new(MockShellExecutor::new())); 128 | 129 | let url = "https://example.com/{{ 'whoami' | shell }}"; 130 | 131 | let result = engine.render_url(url); 132 | assert!(result.is_ok()); 133 | assert_eq!(result.unwrap(), "https://example.com/testuser"); 134 | } 135 | -------------------------------------------------------------------------------- /bkmr/tests/infrastructure/interpolation/mod.rs: -------------------------------------------------------------------------------- 1 | mod minijinja_engine_test; 2 | -------------------------------------------------------------------------------- /bkmr/tests/infrastructure/mod.rs: -------------------------------------------------------------------------------- 1 | mod interpolation; 2 | -------------------------------------------------------------------------------- /bkmr/tests/resources/bkmr.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysid/bkmr/a1bce1bca10712aba836354f98a7cc179045f502/bkmr/tests/resources/bkmr.pptx -------------------------------------------------------------------------------- /bkmr/tests/resources/bkmr.v1.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysid/bkmr/a1bce1bca10712aba836354f98a7cc179045f502/bkmr/tests/resources/bkmr.v1.db -------------------------------------------------------------------------------- /bkmr/tests/resources/bkmr.v2.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysid/bkmr/a1bce1bca10712aba836354f98a7cc179045f502/bkmr/tests/resources/bkmr.v2.db -------------------------------------------------------------------------------- /bkmr/tests/resources/bkmr.v2.noembed.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysid/bkmr/a1bce1bca10712aba836354f98a7cc179045f502/bkmr/tests/resources/bkmr.v2.noembed.db -------------------------------------------------------------------------------- /bkmr/tests/resources/bookmarks.ndjson: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "$VIMWIKI_PATH/linear_programming.md:0", 4 | "title": "linear_programming.md", 5 | "description": "", 6 | "tags": [ 7 | "_imported_" 8 | ] 9 | }, 10 | { 11 | "url": "$VIMWIKI_PATH/personal_intro.md:0", 12 | "title": "personal_intro.md", 13 | "description": "", 14 | "tags": [ 15 | "_imported_" 16 | ] 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /bkmr/tests/resources/data.ndjson: -------------------------------------------------------------------------------- 1 | {"id": "/a/b/readme.md:0", "content": "First record"} 2 | {"id": "/a/b/readme.md:1", "content": "Second record"} 3 | {"id": "/a/b/c/xxx.md:0", "content": "Third record"} -------------------------------------------------------------------------------- /bkmr/tests/resources/invalid_data.ndjson: -------------------------------------------------------------------------------- 1 | {"id": "/a/b/readme.md:0", "content": "First record", "xxx": "not expected but ok"} 2 | {"id": "/a/b/readme.md:1"} 3 | {"id": "/a/b/c/xxx.md:0", "content": "Third record"} -------------------------------------------------------------------------------- /bkmr/tests/resources/sample_docu.md: -------------------------------------------------------------------------------- 1 | # Sample Documentation 2 | 3 | lorem ipsum 4 | 5 | ## SqlAlchemy 6 | bla blub 7 | 8 | 9 | # Other Stuff -------------------------------------------------------------------------------- /bkmr/tests/resources/snips.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "bba \"shell::ankiview -c $HOME/dev/s/private/other-anki/data/tw/collection.anki2 1737647330399\" ankiview --title \"geo\"", 4 | "title": "ankiview", 5 | "description": "", 6 | "tags": [ 7 | "_snip_" 8 | ] 9 | }, 10 | { 11 | "url": "fields @timestamp, @message, message.request.path\n| filter ispresent(message.request.path)\n| sort @timestamp desc\n| stats count(*) by bin(15min)\n| limit 10", 12 | "title": "aws-cloudwatch-histogram", 13 | "description": "", 14 | "tags": [ 15 | "_snip_" 16 | ] 17 | }, 18 | { 19 | "url": "jq -c '.features[] | {name: .properties.name, properties: .properties, geometry: .geometry}' x.json", 20 | "title": "jq-los-route-geojson", 21 | "description": "split geoJson from los-cha trace into json objects for OS index", 22 | "tags": [ 23 | "_snip_" 24 | ] 25 | }, 26 | { 27 | "url": "find . -type d \\( -path '*/.terraform/*' -o -path '*/prod/*' \\) -prune -o -type f -size -4M -mtime +30 -name '*.hcl'", 28 | "title": "find-prune", 29 | "description": "find . -type d \\( -name .terraform -o -name .terragrunt-cache \\) -prune -o \\( -type d -name '.git' -not -path '*/\\.build*' \\) -print\n-print is essential, else the pruned ones will also be printed (default behvior)", 30 | "tags": [ 31 | "_snip_" 32 | ] 33 | }, 34 | { 35 | "url": "%load_ext autoreload\n%autoreload 2\n\nimport pandas as pd\nimport numpy as np\nimport matplotlib.pyplot as plt\nimport json\nimport os\nimport pickle\nimport sys\nimport time\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\nfrom pprint import pprint", 36 | "title": "jupyter", 37 | "description": "", 38 | "tags": [ 39 | "_snip_" 40 | ] 41 | } 42 | ] 43 | -------------------------------------------------------------------------------- /bkmr/tests/test_lib.rs: -------------------------------------------------------------------------------- 1 | mod application; 2 | mod cli; 3 | mod infrastructure; 4 | -------------------------------------------------------------------------------- /bkmr/tests/test_main.rs: -------------------------------------------------------------------------------- 1 | use assert_cmd::Command; 2 | use bkmr::util::testing::EnvGuard; 3 | use predicates::prelude::*; 4 | use serial_test::serial; 5 | use std::fs; 6 | 7 | #[test] 8 | #[serial] 9 | fn given_debug_flag_when_running_then_enables_debug_mode() { 10 | // let config = init_test_env(); 11 | // let _guard = EnvGuard::new(); 12 | let mut cmd = Command::cargo_bin("bkmr").unwrap(); 13 | cmd.args(["-d", "-d"]).assert().success(); 14 | // cmd.args(&["-d", "-d"]) 15 | // .assert() 16 | // .stderr(predicate::str::contains("Debug mode: debug")); 17 | } 18 | 19 | #[test] 20 | #[serial] 21 | #[ignore = "not implemented"] 22 | fn given_path_when_creating_database_then_creates_successfully() { 23 | // let config = init_test_env(); 24 | // let _guard = EnvGuard::new(); 25 | fs::remove_file("/tmp/bkmr_test.db").unwrap_or_default(); 26 | 27 | let mut cmd = Command::cargo_bin("bkmr").unwrap(); 28 | cmd.args(["-d", "-d", "create-db", "/tmp/bkmr_test.db"]) 29 | .assert() 30 | .stdout(predicate::str::contains("Database created")); 31 | } 32 | 33 | #[test] 34 | #[serial] 35 | fn given_bookmark_ids_when_showing_then_displays_correct_entries() { 36 | // let config = init_test_env(); 37 | let _guard = EnvGuard::new(); 38 | fs::remove_file("/tmp/bkmr_test.db").unwrap_or_default(); 39 | 40 | let mut cmd = Command::cargo_bin("bkmr").unwrap(); 41 | cmd.args(["-d", "-d", "show", "1,2"]).assert().success(); 42 | } 43 | -------------------------------------------------------------------------------- /brew/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | create-release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Create Release 13 | uses: actions/create-release@v1 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | with: 17 | tag_name: ${{ github.ref }} 18 | release_name: Release ${{ github.ref }} 19 | draft: false 20 | prerelease: false 21 | 22 | build-and-upload: 23 | needs: create-release 24 | runs-on: ${{ matrix.os }} 25 | strategy: 26 | matrix: 27 | os: [ubuntu-latest, macos-latest, windows-latest] 28 | include: 29 | - os: ubuntu-latest 30 | artifact_name: bkmr 31 | asset_name: bkmr-linux-amd64 32 | - os: macos-latest 33 | artifact_name: bkmr 34 | asset_name: bkmr-macos-amd64 35 | - os: windows-latest 36 | artifact_name: bkmr.exe 37 | asset_name: bkmr-windows-amd64.exe 38 | 39 | steps: 40 | - uses: actions/checkout@v2 41 | 42 | - name: Install Rust 43 | uses: actions-rs/toolchain@v1 44 | with: 45 | toolchain: stable 46 | override: true 47 | 48 | - name: Build 49 | uses: actions-rs/cargo@v1 50 | with: 51 | command: build 52 | args: --release 53 | 54 | - name: Generate Completions 55 | if: runner.os != 'Windows' 56 | run: | 57 | mkdir -p completions 58 | cargo run -- completion bash > completions/bkmr.bash 59 | cargo run -- completion zsh > completions/bkmr.zsh 60 | cargo run -- completion fish > completions/bkmr.fish 61 | 62 | - name: Package (Linux/macOS) 63 | if: runner.os != 'Windows' 64 | run: | 65 | mkdir -p package/bin package/completions 66 | cp target/release/bkmr package/bin/ 67 | cp completions/* package/completions/ || true 68 | cd package 69 | tar -czf ../${{ matrix.asset_name }}.tar.gz . 70 | 71 | - name: Package (Windows) 72 | if: runner.os == 'Windows' 73 | run: | 74 | mkdir -p package/bin 75 | cp target/release/bkmr.exe package/bin/ 76 | cd package 77 | 7z a -tzip ../${{ matrix.asset_name }}.zip . 78 | 79 | - name: Upload Release Asset (Linux/macOS) 80 | if: runner.os != 'Windows' 81 | uses: actions/upload-release-asset@v1 82 | env: 83 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 84 | with: 85 | upload_url: ${{ needs.create-release.outputs.upload_url }} 86 | asset_path: ./${{ matrix.asset_name }}.tar.gz 87 | asset_name: ${{ matrix.asset_name }}.tar.gz 88 | asset_content_type: application/gzip 89 | 90 | - name: Upload Release Asset (Windows) 91 | if: runner.os == 'Windows' 92 | uses: actions/upload-release-asset@v1 93 | env: 94 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 95 | with: 96 | upload_url: ${{ needs.create-release.outputs.upload_url }} 97 | asset_path: ./${{ matrix.asset_name }}.zip 98 | asset_name: ${{ matrix.asset_name }}.zip 99 | asset_content_type: application/zip -------------------------------------------------------------------------------- /db/bkmr.db.bkp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysid/bkmr/a1bce1bca10712aba836354f98a7cc179045f502/db/bkmr.db.bkp -------------------------------------------------------------------------------- /db/queries.sql: -------------------------------------------------------------------------------- 1 | -- name: get_all 2 | select * 3 | from bookmarks; 4 | 5 | select * 6 | from bookmarks_fts 7 | where bookmarks_fts match 'xxx' -- :fts_query 8 | order by rank; 9 | 10 | 11 | /* 12 | For tracking the database version, I use the built in user-version variable that sqlite provides 13 | (sqlite does nothing with this variable, you are free to use it however you please). 14 | It starts at 0, and you can get/set this variable with the following sqlite statements: 15 | */ 16 | -- name: get_user_version 17 | PRAGMA user_version; 18 | 19 | -- PRAGMA user_version = 1; 20 | 21 | -- name: get_related_tags 22 | with RECURSIVE split(tags, rest) AS (SELECT '', tags || ',' 23 | FROM bookmarks 24 | WHERE tags LIKE '%,ccc,%' 25 | UNION ALL 26 | SELECT substr(rest, 0, instr(rest, ',')), 27 | substr(rest, instr(rest, ',') + 1) 28 | FROM split 29 | WHERE rest <> '') 30 | SELECT distinct tags 31 | FROM split 32 | WHERE tags <> '' 33 | ORDER BY tags; 34 | 35 | 36 | -- name: get_all_tags 37 | with RECURSIVE split(tags, rest) AS (SELECT '', tags || ',' 38 | FROM bookmarks 39 | UNION ALL 40 | SELECT substr(rest, 0, instr(rest, ',')), 41 | substr(rest, instr(rest, ',') + 1) 42 | FROM split 43 | WHERE rest <> '') 44 | SELECT distinct tags 45 | FROM split 46 | WHERE tags <> '' 47 | ORDER BY tags; 48 | 49 | SELECT 1 as diesel_exists 50 | FROM sqlite_master WHERE type='table' AND name='__diesel_schema_migrations'; -------------------------------------------------------------------------------- /docs/asciinema/README.md: -------------------------------------------------------------------------------- 1 | # Demos 2 | - 10x zoom 3 | -------------------------------------------------------------------------------- /docs/asciinema/demo-env.sh: -------------------------------------------------------------------------------- 1 | echo "-M- bkmr demo environment" 2 | rm -fr /tmp/bkmr 3 | unset BKMR_DB_URL 4 | unset BKMR_FZF_OPTS 5 | 6 | #export BKMR_DB_URL=~/xxx/bkmr-demos/demo.db 7 | export EDITOR=vim 8 | 9 | export COLUMNS=100 10 | export LINES=30 11 | 12 | mkdir -p /tmp/bkmr > /dev/null 2>&1 13 | #!/bin/bash 14 | NAME="Alice" 15 | 16 | cat <<_EOF_ > /tmp/bkmr/config.toml 17 | db_url = "/tmp/bkmr/bkmr.db" 18 | 19 | [fzf_opts] 20 | height = "50%" 21 | reverse = false 22 | show_tags = false 23 | no_url = false 24 | _EOF_ 25 | 26 | export BKMR_DB_URL=/tmp/bkmr/bkmr.db 27 | 28 | touch ~/xxx/rust-files1.rs 29 | touch ~/xxx/rust-files2.rs 30 | 31 | echo "-M- BKMR_DB_URL: $BKMR_DB_URL" 32 | #tree /tmp/bkmr 33 | -------------------------------------------------------------------------------- /docs/asciinema/demo10_env.sh: -------------------------------------------------------------------------------- 1 | # run in asciinema, 10x zoom 2 | unset FOO 3 | source demo-env.sh 4 | 5 | bkmr create-db --pre-fill /tmp/bkmr/bkmr.db # Create pre-filled demo databas 6 | bkmr search --fzf 7 | clear 8 | 9 | # NOT WORKING (panick) 10 | echo "Alias to source output of bkmr into environment" 11 | alias set-env="source <(bkmr search --fzf --fzf-style enhanced -t _env_)" 12 | clear 13 | 14 | bkmr add -t env # add some variables 15 | echo $FOO # variable not set in environment 16 | set-env 17 | echo $FOO # variable now set 18 | -------------------------------------------------------------------------------- /docs/asciinema/demo11_all.sh: -------------------------------------------------------------------------------- 1 | #doitlive shell: /bin/bash 2 | #doitlive prompt: damoekri 3 | #doitlive speed: 2 4 | #doitlive commentecho: false 5 | #doitlive alias: setup-environment="source $HOME/dev/s/public/bkmr/docs/asciinema/demo-env.sh" 6 | #doitlive env: BKMR_DB_URL=/tmp/bkmr/bkmr.db 7 | 8 | # Source environment and ensure clean state: sss 9 | #asciinema rec -i 4 -t "bkmr: Overview" bkmr4-all.cast 10 | #doitlive play /Users/Q187392/dev/s/public/bkmr/docs/asciinema/demo11_all.sh 11 | 12 | setup-environment 13 | bkmr create-db --pre-fill /tmp/bkmr/bkmr.db 14 | clear 15 | 16 | bkmr info 17 | clear 18 | 19 | # show interactive h 20 | bkmr search -N _snip_,_shell_,_md_,_env_,_imported_ # default view, only URLs 21 | clear 22 | 23 | bkmr search --help | grep fzf # look at the CTRL-x commands 24 | clear 25 | 26 | bkmr search --fzf # view with fzf selection 27 | clear 28 | bkmr search --fzf --fzf-style enhanced --tags _snip_ # snippet view 29 | clear 30 | 31 | # run 4: hello world ENTER 32 | bkmr search --fzf --fzf-style enhanced --tags _shell_ # shell scripts, run with ENTER 33 | clear 34 | 35 | # edit 4 with CTRL-E 36 | bkmr search --fzf --fzf-style enhanced # edit: CTRL-E 37 | clear 38 | 39 | bkmr search -t shell 'hello world' # uniq hit -> default execution 40 | -------------------------------------------------------------------------------- /docs/asciinema/demo1_setup.sh: -------------------------------------------------------------------------------- 1 | #doitlive shell: /bin/bash 2 | #doitlive prompt: damoekri 3 | #doitlive speed: 2 4 | #doitlive env: DOCS_URL=https://doitlive.readthedocs.io 5 | #doitlive commentecho: false 6 | #doitlive alias: setup-environment="source $HOME/dev/s/public/bkmr/docs/asciinema/demo-env.sh" 7 | #doitlive env: BKMR_DB_URL=/tmp/bkmr/bkmr.db 8 | 9 | # Source environment and ensure clean state: sss 10 | #asciinema rec -i 4 -t "bkmr: Getting Started" bkmr4-setup.cast 11 | #doitlive play /Users/Q187392/dev/s/public/bkmr/docs/asciinema/demo1_setup.sh 12 | #asciinema play -i 4 --speed 2 bkmr4-setup.cast 13 | 14 | setup-environment 15 | 16 | echo "Create configuration" 17 | mkdir -p /tmp/bkmr 18 | bkmr --generate-config > /tmp/bkmr/config.toml 19 | more /tmp/bkmr/config.toml 20 | clear 21 | 22 | echo "Initialize database." 23 | bkmr create-db /tmp/bkmr/bkmr.db 24 | export BKMR_DB_URL=/tmp/bkmr/bkmr.db 25 | 26 | echo "Now add some data..." 27 | bkmr add https://rust-lang.org programming,rust,language 28 | bkmr add https://github.com programming,git,collaboration 29 | bkmr add https://news.ycombinator.com news,tech 30 | clear 31 | 32 | echo "List full database content." 33 | bkmr search 34 | echo "URL metadata has been fetched automatically. Nice!" 35 | clear 36 | 37 | echo "Show info about bkmr and its configuration" 38 | bkmr info 39 | clear 40 | 41 | echo "Create pre-loaded demo-database" 42 | rm -vf /tmp/bkmr/bkmr.db 43 | bkmr create-db --pre-fill /tmp/bkmr/bkmr.db 44 | bkmr search 45 | -------------------------------------------------------------------------------- /docs/asciinema/demo2_search_filter.sh: -------------------------------------------------------------------------------- 1 | #doitlive shell: /bin/bash 2 | #doitlive prompt: damoekri 3 | #doitlive speed: 2 4 | #doitlive commentecho: false 5 | #doitlive alias: setup-environment="source $HOME/dev/s/public/bkmr/docs/asciinema/demo-env.sh" 6 | #doitlive env: BKMR_DB_URL=/tmp/bkmr/bkmr.db 7 | 8 | # Source environment and ensure clean state: sss 9 | #asciinema rec -t "bkmr: Search and Filter" bkmr4-search-filter.cast 10 | #doitlive play /Users/Q187392/dev/s/public/bkmr/docs/asciinema/demo2_search_filter.sh 11 | #asciinema play -i 4 --speed 2 bkmr4-search-filter.cast 12 | 13 | # Setup environment, used /tmp/bkmr/bkmr.db 14 | setup-environment 15 | echo "Create pre-filled demo database" 16 | bkmr create-db --pre-fill /tmp/bkmr/bkmr.db 17 | clear 18 | 19 | bkmr search -N _snip_,_imported_ # Search all except entries with tags _snip_, _imported_ 20 | clear 21 | 22 | bkmr search --fzf # use fuzzy finding 23 | clear 24 | 25 | # run 4: hello world 26 | bkmr search --fzf --fzf-style enhanced 27 | echo "FZF actions: Enter: open, CTRL-E: edit, CTRL-D: delete, CTRL-Y: yank" 28 | clear 29 | 30 | # edit 4 with CTRL-E 31 | bkmr search --fzf --fzf-style enhanced 32 | clear 33 | 34 | echo "Search with tags filter, execute the command with 1 ENTER" 35 | bkmr search -t shell 'hello world' 36 | -------------------------------------------------------------------------------- /docs/asciinema/demo3_edit_update.sh: -------------------------------------------------------------------------------- 1 | #doitlive shell: /bin/bash 2 | #doitlive prompt: damoekri 3 | #doitlive speed: 2 4 | #doitlive commentecho: false 5 | #doitlive alias: setup-environment="source $HOME/dev/s/public/bkmr/docs/asciinema/demo-env.sh" 6 | #doitlive env: BKMR_DB_URL=/tmp/bkmr/bkmr.db 7 | 8 | #asciinema rec -i 4 -t "bkmr: Edit and Update" bkmr4-edit-update.cast 9 | #doitlive play /Users/Q187392/dev/s/public/bkmr/docs/asciinema/demo3_edit_update.sh 10 | 11 | setup-environment 12 | echo "Create pre-filled demo database" 13 | bkmr create-db --pre-fill /tmp/bkmr/bkmr.db 14 | clear 15 | 16 | bkmr search 'github' # search for term 'github' 17 | 18 | bkmr update 1 -t xxx # add tag 'xxx' to bookmark with id 1 19 | clear 20 | 21 | # Show the updated bookmark 22 | bkmr search 'github' # look for tag: xxx 23 | 24 | bkmr update 1 -n xxx # remove tag 'xxx' 25 | bkmr search 'github' # look for removed tag: xxx 26 | clear 27 | 28 | bkmr edit 1 # edit bookmark with id 1 29 | # (This will open an editor - make some changes to title/description) 30 | 31 | echo "Delete bookmarks" 32 | bkmr search --limit 2 # delete in interactive mode 33 | -------------------------------------------------------------------------------- /docs/asciinema/demo4_tag_mgmt.sh: -------------------------------------------------------------------------------- 1 | #doitlive shell: /bin/bash 2 | #doitlive prompt: damoekri 3 | #doitlive speed: 2 4 | #doitlive commentecho: false 5 | #doitlive alias: setup-environment="source $HOME/dev/s/public/bkmr/docs/asciinema/demo-env.sh" 6 | #doitlive env: BKMR_DB_URL=/tmp/bkmr/bkmr.db 7 | 8 | #asciinema rec -i 4 -t "bkmr: Tag Management" bkmr4-tag-mgmt.cast 9 | #doitlive play /Users/Q187392/dev/s/public/bkmr/docs/asciinema/demo4_tag_mgmt.sh 10 | 11 | setup-environment 12 | echo "Create pre-filled demo database" 13 | bkmr create-db --pre-fill /tmp/bkmr/bkmr.db 14 | clear 15 | 16 | bkmr search # show the data 17 | clear 18 | 19 | echo "Tag management" 20 | bkmr tags # list all tags and their frequency 21 | 22 | bkmr tags _snip_ # list all tags which occur together with tag '_snip_' 23 | -------------------------------------------------------------------------------- /docs/asciinema/demo5_interactive_fzf.sh: -------------------------------------------------------------------------------- 1 | # Source environment 2 | source $HOME/dev/s/public/b2/docs/asciinema/demo-env.sh 3 | 4 | # Ensure FZF is installed 5 | which fzf || echo "Please install fzf before recording this demo" 6 | 7 | # Add some bookmarks with descriptive titles and varied content to demonstrate fuzzy finding 8 | bkmr add https://tailwindcss.com "Tailwind CSS" -d "A utility-first CSS framework" -t css,frontend,framework 9 | bkmr add https://getbootstrap.com "Bootstrap" -d "The most popular HTML, CSS, and JS library" -t css,frontend,framework 10 | bkmr add https://material-ui.com "Material UI" -d "React components for faster and easier web development" -t react,frontend,components 11 | bkmr add https://bulma.io "Bulma" -d "Free, open source CSS framework" -t css,frontend,framework 12 | 13 | 14 | asciinema rec -t "bkmr: Interactive Features" bkmr_interactive.cast 15 | 16 | bkmr search --fzf 17 | # (Type a few characters to filter) 18 | # (Press ESC to cancel) 19 | 20 | bkmr search --fzf 21 | # (Filter down to a bookmark) 22 | # (Press Enter to select) 23 | # (This would typically open a browser, but for demo purposes it will just show the command) 24 | 25 | bkmr search 26 | # (Show typing a command like "e 1" to edit bookmark 1) 27 | # (Show typing "d 2" to delete bookmark 2) 28 | # (Show typing "p" to print all IDs) 29 | -------------------------------------------------------------------------------- /docs/asciinema/demo6_snips.sh: -------------------------------------------------------------------------------- 1 | # Source environment 2 | source $HOME/dev/s/public/b2/docs/asciinema/demo-env.sh 3 | 4 | # Create a couple of example snippets beforehand to show retrieval 5 | cat > ~/bkmr-demos/rust-error.txt << 'EOF' 6 | // Rust Error Handling Example 7 | fn read_file() -> Result { 8 | let mut file = std::fs::File::open("file.txt")?; 9 | let mut contents = String::new(); 10 | file.read_to_string(&mut contents)?; 11 | Ok(contents) 12 | } 13 | EOF 14 | 15 | cat > ~/bkmr-demos/python-list.txt << 'EOF' 16 | # Python List Comprehension Examples 17 | squares = [x**2 for x in range(10)] 18 | evens = [x for x in range(10) if x % 2 == 0] 19 | matrix_flatten = [item for row in matrix for item in row] 20 | EOF 21 | 22 | # Pre-add one snippet for demonstration 23 | bkmr add --type snip --title "Python List Comprehensions" --tags python,snippet,list "$(cat ~/bkmr-demos/python-list.txt)" 24 | 25 | 26 | asciinema rec -t "bkmr: Advanced Features" bkmr_advanced.cast 27 | 28 | bkmr add --type snip --edit 29 | # (In the editor, add a title like "Rust Error Handling") 30 | # (In the URL section, add a code snippet:) 31 | 32 | fn main() -> Result<(), Box> { 33 | let file = std::fs::File::open("file.txt")?; 34 | Ok(()) 35 | } 36 | 37 | bkmr search snip 38 | bkmr show 7 # Replace with the actual ID 39 | 40 | bkmr add --edit 41 | # (Show the template format and how it guides data entry) -------------------------------------------------------------------------------- /docs/asciinema/demo7_import_export.sh: -------------------------------------------------------------------------------- 1 | # Source environment 2 | source $HOME/dev/s/public/b2/docs/asciinema/demo-env.sh 3 | 4 | # Create a sample JSON file for import demonstration 5 | cat > ~/bkmr-demos/import_bookmarks.json << 'EOF' 6 | [ 7 | { 8 | "url": "https://www.mozilla.org", 9 | "title": "Mozilla", 10 | "description": "Mozilla Foundation website", 11 | "tags": ["browser", "opensource", "firefox"] 12 | }, 13 | { 14 | "url": "https://kubernetes.io", 15 | "title": "Kubernetes", 16 | "description": "Container orchestration platform", 17 | "tags": ["cloud", "container", "devops"] 18 | }, 19 | { 20 | "url": "https://www.docker.com", 21 | "title": "Docker", 22 | "description": "Containerization platform", 23 | "tags": ["container", "devops", "development"] 24 | }, 25 | { 26 | "url": "https://www.terraform.io", 27 | "title": "Terraform", 28 | "description": "Infrastructure as code software tool", 29 | "tags": ["infrastructure", "devops", "cloud"] 30 | } 31 | ] 32 | EOF 33 | 34 | # Ensure we have different tags in existing bookmarks to show contrast 35 | bkmr add https://nodejs.org "Node.js" -d "JavaScript runtime built on Chrome's V8 JavaScript engine" -t javascript,backend,runtime 36 | 37 | 38 | asciinema rec -t "bkmr: Import & Export" bkmr_import_export.cast 39 | 40 | # Create a JSON export of all bookmarks 41 | bkmr search --json > bookmarks.json 42 | 43 | # Show the exported file 44 | cat bookmarks.json | head -20 45 | 46 | bkmr load-json import_example.json 47 | 48 | # Verify the imported bookmarks 49 | bkmr search mozilla 50 | bkmr search kubernetes -------------------------------------------------------------------------------- /docs/asciinema/demo8_surprise.sh: -------------------------------------------------------------------------------- 1 | # Source environment 2 | source ~/bkmr-demos/demo-env.sh 3 | 4 | # Create a sample JSON file for import demonstration 5 | cat > ~/bkmr-demos/import_bookmarks.json << 'EOF' 6 | [ 7 | { 8 | "url": "https://www.mozilla.org", 9 | "title": "Mozilla", 10 | "description": "Mozilla Foundation website", 11 | "tags": ["browser", "opensource", "firefox"] 12 | }, 13 | { 14 | "url": "https://kubernetes.io", 15 | "title": "Kubernetes", 16 | "description": "Container orchestration platform", 17 | "tags": ["cloud", "container", "devops"] 18 | }, 19 | { 20 | "url": "https://www.docker.com", 21 | "title": "Docker", 22 | "description": "Containerization platform", 23 | "tags": ["container", "devops", "development"] 24 | }, 25 | { 26 | "url": "https://www.terraform.io", 27 | "title": "Terraform", 28 | "description": "Infrastructure as code software tool", 29 | "tags": ["infrastructure", "devops", "cloud"] 30 | } 31 | ] 32 | EOF 33 | 34 | # Ensure we have different tags in existing bookmarks to show contrast 35 | bkmr add https://nodejs.org "Node.js" -d "JavaScript runtime built on Chrome's V8 JavaScript engine" -t javascript,backend,runtime -------------------------------------------------------------------------------- /docs/asciinema/demo9_semantic_search.sh: -------------------------------------------------------------------------------- 1 | # Source environment 2 | source $HOME/dev/s/public/b2/docs/asciinema/demo-env.sh 3 | 4 | # Set up OpenAI API key (if you're demonstrating this feature) 5 | export OPENAI_API_KEY="your-api-key-here" # Replace with actual key or use a placeholder 6 | 7 | # Add bookmarks with rich descriptions for semantic search 8 | bkmr add --openai https://arxiv.org/abs/1706.03762 "Attention Is All You Need" -d "The original paper that introduced the transformer architecture, which has revolutionized natural language processing and many other areas of machine learning" -t ai,nlp,research,paper 9 | bkmr add --openai https://jalammar.github.io/illustrated-transformer/ "The Illustrated Transformer" -d "A visual and intuitive explanation of how transformers work in natural language processing" -t ai,nlp,tutorial,visualization 10 | bkmr add --openai https://arxiv.org/abs/1810.04805 "BERT Paper" -d "Bidirectional Encoder Representations from Transformers, a groundbreaking language representation model" -t ai,nlp,bert,research 11 | bkmr add --openai https://openai.com/research/chatgpt "ChatGPT Blog Post" -d "OpenAI's article introducing ChatGPT, a conversational AI model trained to be helpful, harmless, and honest" -t ai,chatgpt,conversational,llm 12 | 13 | # Backfill embeddings 14 | bkmr backfill --openai 15 | 16 | asciinema rec -t "bkmr: Semantic Search" bkmr_semantic_search.cast 17 | 18 | echo "Let's try semantic search capabilities where we search by meaning rather than keywords" 19 | 20 | bkmr semsearch "How do transformers work in deep learning?" 21 | 22 | -------------------------------------------------------------------------------- /docs/bkmr.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysid/bkmr/a1bce1bca10712aba836354f98a7cc179045f502/docs/bkmr.pptx -------------------------------------------------------------------------------- /docs/bkmr4-bookmarks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysid/bkmr/a1bce1bca10712aba836354f98a7cc179045f502/docs/bkmr4-bookmarks.png -------------------------------------------------------------------------------- /docs/bkmr4-fzf-snippets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysid/bkmr/a1bce1bca10712aba836354f98a7cc179045f502/docs/bkmr4-fzf-snippets.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "bkmr" 3 | version = "4.23.8" 4 | description = "Super fast bookmark manager with semantic full text search'" 5 | authors = [ 6 | {name = "sysid", email = "sysid@gmx.de"}, 7 | ] 8 | readme = "README.md" 9 | requires-python = ">=3.10" 10 | classifiers = [ 11 | "Programming Language :: Rust", 12 | "Programming Language :: Python :: Implementation :: CPython", 13 | ] 14 | dependencies = [] 15 | license = {text = "BSD-3-Clause"} 16 | 17 | [build-system] 18 | requires = ["maturin>=1,<2"] 19 | build-backend = "maturin" 20 | 21 | [tool.uv] 22 | managed = true 23 | dev-dependencies = [ 24 | "pip>=24.2", 25 | "pytest>=8.3.2", 26 | "pytest-mock>=3.14.0", 27 | "ruff>=0.6.1", 28 | "isort>=5.13.2", 29 | "mypy>=1.11.1", 30 | ] 31 | 32 | [tool.maturin] 33 | bindings = "bin" 34 | strip = true 35 | cargo-manifest-path = "bkmr/Cargo.toml" 36 | scripts = { bkmr = "bkmr" } 37 | 38 | [tool.hatch.metadata] 39 | allow-direct-references = true 40 | 41 | [tool.hatch.build.targets.wheel] 42 | packages = ["py"] 43 | 44 | [tool.bumpversion] 45 | current_version = "4.23.8" 46 | parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" 47 | serialize = ["{major}.{minor}.{patch}"] 48 | #search = "{current_version}" 49 | #replace = "{new_version}" 50 | search = "version = \"{current_version}\"" # Updated to match the full line precisely 51 | replace = "version = \"{new_version}\"" # Matches the format of the search pattern 52 | regex = true 53 | ignore_missing_version = false 54 | tag = true 55 | sign_tags = false 56 | tag_name = "v{new_version}" 57 | tag_message = "Bump version: {current_version} → {new_version}" 58 | allow_dirty = false 59 | commit = true 60 | message = "Bump version: {current_version} → {new_version}" 61 | commit_args = "" 62 | 63 | [[tool.bumpversion.files]] 64 | filename = "VERSION" 65 | search = "{current_version}" 66 | replace = "{new_version}" 67 | regex = false 68 | 69 | [[tool.bumpversion.files]] 70 | filename = "pyproject.toml" 71 | 72 | [[tool.bumpversion.files]] 73 | filename = "bkmr/Cargo.toml" 74 | -------------------------------------------------------------------------------- /resources/documentation.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysid/bkmr/a1bce1bca10712aba836354f98a7cc179045f502/resources/documentation.pptx -------------------------------------------------------------------------------- /resources/sem_search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysid/bkmr/a1bce1bca10712aba836354f98a7cc179045f502/resources/sem_search.png -------------------------------------------------------------------------------- /resources/sem_search_vs_fts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysid/bkmr/a1bce1bca10712aba836354f98a7cc179045f502/resources/sem_search_vs_fts.png -------------------------------------------------------------------------------- /scratch/asciinema_script.txt: -------------------------------------------------------------------------------- 1 | bkmr search -t optimization -> h p 2 | 3 | bkmr add -e https://wwww.google.com xxx,yyy --title 'xxxxxx' 4 | bkmr search -t xxx # d 1 5 | 6 | bkmr search -N admin,ob google 7 | 8 | bkmr search -t optimization --fzf # schedulingteacher 9 | bkmr show 11,12 10 | -------------------------------------------------------------------------------- /scratch/create.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_imports, unused_variables)] 2 | use std::io::{Read, stdin}; 3 | use bkmr::dal::{create_bookmark, establish_connection}; 4 | 5 | fn main() { 6 | let connection = &mut establish_connection(); 7 | 8 | println!("What would you like your title to be?"); 9 | let mut title = String::new(); 10 | stdin().read_line(&mut title).unwrap(); 11 | let title = &title[..(title.len() - 1)]; // Drop the newline character 12 | println!( 13 | "\nOk! Let's write {} (Press {} when finished)\n", 14 | title, EOF 15 | ); 16 | let mut body = String::new(); 17 | stdin().read_to_string(&mut body).unwrap(); 18 | 19 | // let _ = create_bookmark(connection, title, &body); 20 | // println!("\nSaved draft {}", title); 21 | } 22 | 23 | #[cfg(not(windows))] 24 | const EOF: &str = "CTRL+D"; 25 | 26 | #[cfg(windows)] 27 | const EOF: &str = "CTRL+Z"; 28 | -------------------------------------------------------------------------------- /scratch/delete.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_imports, unused_variables)] 2 | use diesel::prelude::*; 3 | use std::env::args; 4 | use bkmr::dal::establish_connection; 5 | use bkmr::schema::bookmarks::dsl::bookmarks; 6 | use bkmr::schema::bookmarks::URL; 7 | 8 | fn main() { 9 | let target = args().nth(1).expect("Expected a target to match against"); 10 | let pattern = format!("%{}%", target); 11 | 12 | let connection = &mut establish_connection(); 13 | let num_deleted = diesel::delete(bookmarks.filter(URL.like(pattern))) 14 | .execute(connection) 15 | .expect("Error deleting bookmarks"); 16 | 17 | println!("Deleted {} bookmarks", num_deleted); 18 | } 19 | -------------------------------------------------------------------------------- /scratch/migrate.rs: -------------------------------------------------------------------------------- 1 | use diesel::SqliteConnection; 2 | use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; 3 | 4 | use bkmr::dal::establish_connection; 5 | 6 | pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations"); 7 | 8 | // embed_migrations!("./migrations"); 9 | 10 | fn main() { 11 | let conn = &mut establish_connection(); 12 | run_migration(conn); 13 | } 14 | 15 | fn run_migration(conn: &mut SqliteConnection) { 16 | conn.run_pending_migrations(MIGRATIONS).unwrap(); 17 | // conn.revert_last_migration(MIGRATIONS).unwrap(); 18 | } 19 | -------------------------------------------------------------------------------- /scratch/read.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_imports, unused_variables)] 2 | use diesel::prelude::*; 3 | use bkmr::dal::establish_connection; 4 | use bkmr::models::Bookmark; 5 | use bkmr::schema::bookmarks::dsl::bookmarks; 6 | 7 | #[allow(unused_imports)] 8 | use log::{debug, error, log_enabled, info, Level}; 9 | use bkmr::schema::bookmarks::flags; 10 | 11 | fn main() { 12 | env_logger::init(); 13 | let connection = &mut establish_connection(); 14 | let results = bookmarks 15 | .filter(flags.eq(0)) 16 | .limit(5) 17 | .load::(connection) 18 | .expect("Error loading bookmarks"); 19 | 20 | println!("Displaying {} bookmarks", results.len()); 21 | error!("Hello, world!"); 22 | for bm in results { 23 | println!("{}", bm.URL); 24 | println!("----------\n"); 25 | println!("{}", bm.tags); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /scratch/update.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_imports, unused_variables)] 2 | use diesel::prelude::*; 3 | use std::env::args; 4 | use bkmr::schema::bookmarks::dsl::bookmarks; 5 | use bkmr::models; 6 | use bkmr::dal::establish_connection; 7 | 8 | fn main() { 9 | let id = args() 10 | .nth(1) 11 | .expect("publish_bookmark requires a bookmark id") 12 | .parse::() 13 | .expect("Invalid ID"); 14 | let connection = &mut establish_connection(); 15 | 16 | // let _ = diesel::update(bookmarks.find(id)) 17 | // .set(published.eq(true)) 18 | // .execute(connection) 19 | // .unwrap(); 20 | // 21 | // let bookmark: models::Bookmark = bookmarks 22 | // .find(id) 23 | // .first(connection) 24 | // .unwrap_or_else(|_| panic!("Unable to find bookmark {}", id)); 25 | // 26 | // println!("Published bookmark {}", bookmark.title); 27 | } 28 | -------------------------------------------------------------------------------- /scripts/openapi_embed.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from openai import OpenAI 4 | client = OpenAI( 5 | # This is the default and can be omitted 6 | api_key=os.environ.get("OPENAI_API_KEY"), 7 | ) 8 | 9 | # # Replace "your_api_key" with your actual OpenAI API key 10 | # openai.api_key = os.getenv("OPENAI_API_KEY") 11 | # print(openai.api_key) 12 | 13 | 14 | def get_embeddings(text: str) -> list: 15 | response = client.embeddings.create( 16 | input=text.replace("\n", " "), 17 | model="text-embedding-ada-002" # Example model; choose the model as per your requirement 18 | ) 19 | embeddings = response.data[0].embedding # This extracts the embedding vector 20 | return embeddings 21 | 22 | 23 | # Example usage 24 | text = "Hello, world!" 25 | embeddings = get_embeddings(text) 26 | print(embeddings) 27 | -------------------------------------------------------------------------------- /sql/check_embed.sql: -------------------------------------------------------------------------------- 1 | select * from bookmarks 2 | WHERE embeddable = 1 and tags 3 | -- WHERE embeddable = 0 and not content_hash IS NULL; 4 | 5 | UPDATE bookmarks 6 | SET content_hash = NULL 7 | WHERE embeddable = 0 OR embedding IS NULL; 8 | 9 | UPDATE bookmarks 10 | SET embedding = NULL 11 | WHERE embeddable = 0; 12 | -------------------------------------------------------------------------------- /sql/check_schema.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Database file to check 4 | DB_FILE="$1" 5 | 6 | # Expected schema elements 7 | expected_tables=("bookmarks" "bookmarks_fts" "bookmarks_fts_data" "bookmarks_fts_idx" "bookmarks_fts_docsize" "bookmarks_fts_config" "__diesel_schema_migrations") 8 | expected_triggers=("bookmarks_ai" "bookmarks_ad" "bookmarks_au" "UpdateLastTime") 9 | 10 | # Function to check for table existence 11 | check_table() { 12 | local table=$1 13 | local result=$(sqlite3 "$DB_FILE" "SELECT name FROM sqlite_master WHERE type='table' AND name='$table';") 14 | echo "-M- Checking table: $table" 15 | if [ -z "$result" ]; then 16 | echo "Missing table: $table" 17 | return 1 18 | fi 19 | } 20 | 21 | # Function to check for trigger existence 22 | check_trigger() { 23 | local trigger=$1 24 | local result=$(sqlite3 "$DB_FILE" "SELECT name FROM sqlite_master WHERE type='trigger' AND name='$trigger';") 25 | echo "-M- Checking trigger: $trigger" 26 | if [ -z "$result" ]; then 27 | echo "Missing trigger: $trigger" 28 | return 1 29 | fi 30 | } 31 | 32 | # Check for each table 33 | for table in "${expected_tables[@]}"; do 34 | check_table "$table" || exit 1 35 | done 36 | 37 | # Check for each trigger 38 | for trigger in "${expected_triggers[@]}"; do 39 | check_trigger "$trigger" || exit 1 40 | done 41 | 42 | echo "All expected tables and triggers exist in the database." 43 | 44 | -------------------------------------------------------------------------------- /sql/compact.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | DELETE 3 | FROM bookmarks 4 | WHERE id = :deleted_id; 5 | UPDATE bookmarks 6 | SET id = id - 1 7 | WHERE id > :deleted_id; 8 | COMMIT; 9 | 10 | BEGIN TRANSACTION; 11 | DELETE 12 | FROM bookmarks 13 | WHERE id = :deleted_id 14 | returning *; 15 | UPDATE bookmarks 16 | SET id = id - 1 17 | WHERE id > :deleted_id; 18 | COMMIT; 19 | 20 | -- Variant 2 21 | BEGIN TRANSACTION; 22 | 23 | -- create a temporary table with all rows except the one to be deleted 24 | CREATE TEMP TABLE temp AS 25 | SELECT * 26 | FROM book 27 | WHERE id != {id_to_delete}; 28 | 29 | -- delete the original table 30 | DROP TABLE table_name; 31 | 32 | -- rename the temporary table to the original table 33 | ALTER TABLE temp 34 | RENAME TO table_name; 35 | 36 | COMMIT; 37 | -------------------------------------------------------------------------------- /sql/experiments.sql: -------------------------------------------------------------------------------- 1 | SELECT * 2 | FROM bookmarks 3 | WHERE tags NOT LIKE '%_shell_%' 4 | AND tags NOT LIKE '%_snip_%' 5 | and embeddable 6 | ; 7 | -------------------------------------------------------------------------------- /sql/find_nulls.sql: -------------------------------------------------------------------------------- 1 | select * 2 | from bookmarks 3 | where id is null 4 | or URL is null 5 | or metadata is null 6 | or tags is null 7 | or desc is null 8 | or flags is null 9 | or last_update_ts is null 10 | ; 11 | 12 | delete from bookmarks 13 | where id = 1607 14 | ; 15 | --------------------------------------------------------------------------------