├── .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 |
5 |
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 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/.idea/jpa-buddy.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/All Tests.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Test main__tests.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Test tag.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Test tag__test__test_clean_tags.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Test tag__test__test_create_normalized_tag_string.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Test tag__test__test_tags.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Test test_bms.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Test test_check_tags.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Test test_create_db.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Test test_dal__test_bm_exists.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Test test_dal__test_get_bookmarks.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Test test_embeddings.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Test test_update_bm.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Test_environment.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Test_test_dal.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Test_test_process.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Tests in 'tests'.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/_template__of_Cargo.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/test_clean_tags.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/test_create_normalized_tag_string.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/test_delete_bms.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/test_do_sth_with_bms.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/test_ensure_int_vector.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/test_environment.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/test_get_bookmarks.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/test_init_db.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/test_lib.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/test_open_bm.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/test_open_bms.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/test_parse_tags.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/test_print_ids.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/test_process.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/test_show_bms.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/twbm add.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/twbm delete.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/twbm edit.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/twbm search.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/twbm show.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/twbm update.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
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 |
--------------------------------------------------------------------------------