├── .github └── workflows │ ├── release.yaml │ └── rust-build.yaml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── assets ├── bookmark-demo.gif ├── bookmark_logo.png └── bookmark_logo_text.png ├── docs ├── import-from-previous-version.md └── usage.md └── src ├── bin ├── cmd │ └── mod.rs ├── display │ └── mod.rs ├── interactive │ ├── bookmarks_table.rs │ ├── event.rs │ ├── helpers.rs │ ├── interactive_mode.rs │ ├── interface.rs │ ├── mod.rs │ ├── modules │ │ ├── command.rs │ │ ├── delete.rs │ │ ├── help.rs │ │ ├── mod.rs │ │ └── search.rs │ ├── subcommand │ │ ├── add.rs │ │ └── mod.rs │ ├── table.rs │ ├── url_table_item.rs │ └── widgets │ │ ├── mod.rs │ │ └── rect.rs └── main.rs └── lib ├── filters.rs ├── import ├── mod.rs └── v0_0_x.rs ├── lib.rs ├── registry.rs ├── sort.rs ├── storage.rs ├── types.rs └── util.rs /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Create Release Artifacts 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | workflow_dispatch: {} 7 | jobs: 8 | build: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [macos-latest, macos-13, ubuntu-latest] 13 | include: 14 | - os: macos-latest 15 | TARGET: aarch64-apple-darwin 16 | ARTIFACT_NAME: bookmark-darwin-aarch64 17 | - os: macos-13 18 | TARGET: x86_64-apple-darwin 19 | ARTIFACT_NAME: bookmark-darwin-x86_64 20 | - os: ubuntu-latest 21 | TARGET: x86_64-unknown-linux-gnu 22 | ARTIFACT_NAME: bookmark-linux-amd64 23 | steps: 24 | - uses: actions/checkout@v1 25 | - run: echo ${{matrix.TARGET}} 26 | - name: Build Artifacts 27 | run: cargo build --verbose --release --target ${{matrix.TARGET}} 28 | - name: Rename Artifacts 29 | run: mv ./target/${{matrix.TARGET}}/release/bookmark ${{matrix.ARTIFACT_NAME}} 30 | 31 | - name: Check if Prerelease 32 | run: | 33 | TAG_NAME=$(echo $GITHUB_REF | sed 's|refs/tags/||') 34 | echo "Tag Name: $TAG_NAME" 35 | if [[ "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 36 | echo "IS_PRERELEASE=false" >> $GITHUB_ENV 37 | else 38 | echo "IS_PRERELEASE=true" >> $GITHUB_ENV 39 | fi 40 | - name: Upload Release Asset 41 | id: upload-release-asset 42 | uses: softprops/action-gh-release@v2 43 | with: 44 | prerelease: ${{ env.IS_PRERELEASE }} 45 | files: | 46 | bookmark-darwin-aarch64 47 | bookmark-darwin-x86_64 48 | bookmark-linux-amd64 49 | -------------------------------------------------------------------------------- /.github/workflows/rust-build.yaml: -------------------------------------------------------------------------------- 1 | name: Build And Test 2 | on: 3 | push: 4 | branches: [ '*' ] 5 | pull_request: 6 | branches: [ master ] 7 | jobs: 8 | build: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [macos-latest, ubuntu-latest] 13 | include: 14 | - os: macos-latest 15 | TOOLCHAIN: stable-aarch64-apple-darwin 16 | - os: macos-13 17 | TOOLCHAIN: stable-x86_64-apple-darwin 18 | - os: ubuntu-latest 19 | TOOLCHAIN: stable-x86_64-unknown-linux-gnu 20 | steps: 21 | - uses: actions/checkout@v1 22 | - name: Run Tests 23 | run: cargo test --verbose 24 | - name: Add Clippy 25 | run: rustup component add clippy --toolchain ${{matrix.TOOLCHAIN}} 26 | - name: Run Clippy 27 | run: cargo clippy --verbose 28 | - name: Build 29 | run: cargo build --verbose --release 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | *.iml 3 | /.vscode 4 | 5 | # Generated by Cargo 6 | # will have compiled files and executables 7 | /target/ 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | # Used for manual testing 13 | /test-config 14 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "allocator-api2" 16 | version = "0.2.21" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 19 | 20 | [[package]] 21 | name = "anstream" 22 | version = "0.6.18" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 25 | dependencies = [ 26 | "anstyle", 27 | "anstyle-parse", 28 | "anstyle-query", 29 | "anstyle-wincon", 30 | "colorchoice", 31 | "is_terminal_polyfill", 32 | "utf8parse", 33 | ] 34 | 35 | [[package]] 36 | name = "anstyle" 37 | version = "1.0.10" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 40 | 41 | [[package]] 42 | name = "anstyle-parse" 43 | version = "0.2.6" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 46 | dependencies = [ 47 | "utf8parse", 48 | ] 49 | 50 | [[package]] 51 | name = "anstyle-query" 52 | version = "1.1.2" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 55 | dependencies = [ 56 | "windows-sys 0.59.0", 57 | ] 58 | 59 | [[package]] 60 | name = "anstyle-wincon" 61 | version = "3.0.6" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" 64 | dependencies = [ 65 | "anstyle", 66 | "windows-sys 0.59.0", 67 | ] 68 | 69 | [[package]] 70 | name = "autocfg" 71 | version = "1.4.0" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 74 | 75 | [[package]] 76 | name = "bitflags" 77 | version = "1.3.2" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 80 | 81 | [[package]] 82 | name = "bitflags" 83 | version = "2.6.0" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 86 | 87 | [[package]] 88 | name = "bookmark" 89 | version = "0.2.3" 90 | dependencies = [ 91 | "clap", 92 | "dirs", 93 | "hex", 94 | "open", 95 | "rand", 96 | "ratatui", 97 | "regex", 98 | "serde", 99 | "serde_json", 100 | "termion 1.5.6", 101 | ] 102 | 103 | [[package]] 104 | name = "byteorder" 105 | version = "1.5.0" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 108 | 109 | [[package]] 110 | name = "cassowary" 111 | version = "0.3.0" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 114 | 115 | [[package]] 116 | name = "castaway" 117 | version = "0.2.3" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" 120 | dependencies = [ 121 | "rustversion", 122 | ] 123 | 124 | [[package]] 125 | name = "cfg-if" 126 | version = "0.1.10" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 129 | 130 | [[package]] 131 | name = "cfg-if" 132 | version = "1.0.0" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 135 | 136 | [[package]] 137 | name = "clap" 138 | version = "4.5.23" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" 141 | dependencies = [ 142 | "clap_builder", 143 | ] 144 | 145 | [[package]] 146 | name = "clap_builder" 147 | version = "4.5.23" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" 150 | dependencies = [ 151 | "anstream", 152 | "anstyle", 153 | "clap_lex", 154 | "strsim", 155 | ] 156 | 157 | [[package]] 158 | name = "clap_lex" 159 | version = "0.7.4" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 162 | 163 | [[package]] 164 | name = "colorchoice" 165 | version = "1.0.3" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 168 | 169 | [[package]] 170 | name = "compact_str" 171 | version = "0.7.1" 172 | source = "registry+https://github.com/rust-lang/crates.io-index" 173 | checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" 174 | dependencies = [ 175 | "castaway", 176 | "cfg-if 1.0.0", 177 | "itoa", 178 | "ryu", 179 | "static_assertions", 180 | ] 181 | 182 | [[package]] 183 | name = "crossterm" 184 | version = "0.27.0" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" 187 | dependencies = [ 188 | "bitflags 2.6.0", 189 | "crossterm_winapi", 190 | "libc", 191 | "mio", 192 | "parking_lot", 193 | "signal-hook", 194 | "signal-hook-mio", 195 | "winapi", 196 | ] 197 | 198 | [[package]] 199 | name = "crossterm_winapi" 200 | version = "0.9.1" 201 | source = "registry+https://github.com/rust-lang/crates.io-index" 202 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 203 | dependencies = [ 204 | "winapi", 205 | ] 206 | 207 | [[package]] 208 | name = "dirs" 209 | version = "2.0.2" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "13aea89a5c93364a98e9b37b2fa237effbb694d5cfe01c5b70941f7eb087d5e3" 212 | dependencies = [ 213 | "cfg-if 0.1.10", 214 | "dirs-sys", 215 | ] 216 | 217 | [[package]] 218 | name = "dirs-sys" 219 | version = "0.3.7" 220 | source = "registry+https://github.com/rust-lang/crates.io-index" 221 | checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" 222 | dependencies = [ 223 | "libc", 224 | "redox_users", 225 | "winapi", 226 | ] 227 | 228 | [[package]] 229 | name = "either" 230 | version = "1.13.0" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" 233 | 234 | [[package]] 235 | name = "equivalent" 236 | version = "1.0.1" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 239 | 240 | [[package]] 241 | name = "foldhash" 242 | version = "0.1.4" 243 | source = "registry+https://github.com/rust-lang/crates.io-index" 244 | checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" 245 | 246 | [[package]] 247 | name = "getrandom" 248 | version = "0.1.16" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" 251 | dependencies = [ 252 | "cfg-if 1.0.0", 253 | "libc", 254 | "wasi 0.9.0+wasi-snapshot-preview1", 255 | ] 256 | 257 | [[package]] 258 | name = "getrandom" 259 | version = "0.2.15" 260 | source = "registry+https://github.com/rust-lang/crates.io-index" 261 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 262 | dependencies = [ 263 | "cfg-if 1.0.0", 264 | "libc", 265 | "wasi 0.11.0+wasi-snapshot-preview1", 266 | ] 267 | 268 | [[package]] 269 | name = "hashbrown" 270 | version = "0.15.2" 271 | source = "registry+https://github.com/rust-lang/crates.io-index" 272 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 273 | dependencies = [ 274 | "allocator-api2", 275 | "equivalent", 276 | "foldhash", 277 | ] 278 | 279 | [[package]] 280 | name = "heck" 281 | version = "0.5.0" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 284 | 285 | [[package]] 286 | name = "hex" 287 | version = "0.4.3" 288 | source = "registry+https://github.com/rust-lang/crates.io-index" 289 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 290 | 291 | [[package]] 292 | name = "is_terminal_polyfill" 293 | version = "1.70.1" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 296 | 297 | [[package]] 298 | name = "itertools" 299 | version = "0.13.0" 300 | source = "registry+https://github.com/rust-lang/crates.io-index" 301 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 302 | dependencies = [ 303 | "either", 304 | ] 305 | 306 | [[package]] 307 | name = "itoa" 308 | version = "1.0.14" 309 | source = "registry+https://github.com/rust-lang/crates.io-index" 310 | checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" 311 | 312 | [[package]] 313 | name = "libc" 314 | version = "0.2.169" 315 | source = "registry+https://github.com/rust-lang/crates.io-index" 316 | checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" 317 | 318 | [[package]] 319 | name = "libredox" 320 | version = "0.1.3" 321 | source = "registry+https://github.com/rust-lang/crates.io-index" 322 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 323 | dependencies = [ 324 | "bitflags 2.6.0", 325 | "libc", 326 | "redox_syscall 0.5.8", 327 | ] 328 | 329 | [[package]] 330 | name = "lock_api" 331 | version = "0.4.12" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 334 | dependencies = [ 335 | "autocfg", 336 | "scopeguard", 337 | ] 338 | 339 | [[package]] 340 | name = "log" 341 | version = "0.4.22" 342 | source = "registry+https://github.com/rust-lang/crates.io-index" 343 | checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" 344 | 345 | [[package]] 346 | name = "lru" 347 | version = "0.12.5" 348 | source = "registry+https://github.com/rust-lang/crates.io-index" 349 | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 350 | dependencies = [ 351 | "hashbrown", 352 | ] 353 | 354 | [[package]] 355 | name = "memchr" 356 | version = "2.7.4" 357 | source = "registry+https://github.com/rust-lang/crates.io-index" 358 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 359 | 360 | [[package]] 361 | name = "mio" 362 | version = "0.8.11" 363 | source = "registry+https://github.com/rust-lang/crates.io-index" 364 | checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" 365 | dependencies = [ 366 | "libc", 367 | "log", 368 | "wasi 0.11.0+wasi-snapshot-preview1", 369 | "windows-sys 0.48.0", 370 | ] 371 | 372 | [[package]] 373 | name = "numtoa" 374 | version = "0.1.0" 375 | source = "registry+https://github.com/rust-lang/crates.io-index" 376 | checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" 377 | 378 | [[package]] 379 | name = "numtoa" 380 | version = "0.2.4" 381 | source = "registry+https://github.com/rust-lang/crates.io-index" 382 | checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" 383 | 384 | [[package]] 385 | name = "open" 386 | version = "1.7.1" 387 | source = "registry+https://github.com/rust-lang/crates.io-index" 388 | checksum = "dcea7a30d6b81a2423cc59c43554880feff7b57d12916f231a79f8d6d9470201" 389 | dependencies = [ 390 | "pathdiff", 391 | "winapi", 392 | ] 393 | 394 | [[package]] 395 | name = "parking_lot" 396 | version = "0.12.3" 397 | source = "registry+https://github.com/rust-lang/crates.io-index" 398 | checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" 399 | dependencies = [ 400 | "lock_api", 401 | "parking_lot_core", 402 | ] 403 | 404 | [[package]] 405 | name = "parking_lot_core" 406 | version = "0.9.10" 407 | source = "registry+https://github.com/rust-lang/crates.io-index" 408 | checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 409 | dependencies = [ 410 | "cfg-if 1.0.0", 411 | "libc", 412 | "redox_syscall 0.5.8", 413 | "smallvec", 414 | "windows-targets 0.52.6", 415 | ] 416 | 417 | [[package]] 418 | name = "paste" 419 | version = "1.0.15" 420 | source = "registry+https://github.com/rust-lang/crates.io-index" 421 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 422 | 423 | [[package]] 424 | name = "pathdiff" 425 | version = "0.2.3" 426 | source = "registry+https://github.com/rust-lang/crates.io-index" 427 | checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" 428 | 429 | [[package]] 430 | name = "ppv-lite86" 431 | version = "0.2.20" 432 | source = "registry+https://github.com/rust-lang/crates.io-index" 433 | checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" 434 | dependencies = [ 435 | "zerocopy", 436 | ] 437 | 438 | [[package]] 439 | name = "proc-macro2" 440 | version = "1.0.92" 441 | source = "registry+https://github.com/rust-lang/crates.io-index" 442 | checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" 443 | dependencies = [ 444 | "unicode-ident", 445 | ] 446 | 447 | [[package]] 448 | name = "quote" 449 | version = "1.0.38" 450 | source = "registry+https://github.com/rust-lang/crates.io-index" 451 | checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" 452 | dependencies = [ 453 | "proc-macro2", 454 | ] 455 | 456 | [[package]] 457 | name = "rand" 458 | version = "0.7.3" 459 | source = "registry+https://github.com/rust-lang/crates.io-index" 460 | checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" 461 | dependencies = [ 462 | "getrandom 0.1.16", 463 | "libc", 464 | "rand_chacha", 465 | "rand_core", 466 | "rand_hc", 467 | ] 468 | 469 | [[package]] 470 | name = "rand_chacha" 471 | version = "0.2.2" 472 | source = "registry+https://github.com/rust-lang/crates.io-index" 473 | checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" 474 | dependencies = [ 475 | "ppv-lite86", 476 | "rand_core", 477 | ] 478 | 479 | [[package]] 480 | name = "rand_core" 481 | version = "0.5.1" 482 | source = "registry+https://github.com/rust-lang/crates.io-index" 483 | checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" 484 | dependencies = [ 485 | "getrandom 0.1.16", 486 | ] 487 | 488 | [[package]] 489 | name = "rand_hc" 490 | version = "0.2.0" 491 | source = "registry+https://github.com/rust-lang/crates.io-index" 492 | checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" 493 | dependencies = [ 494 | "rand_core", 495 | ] 496 | 497 | [[package]] 498 | name = "ratatui" 499 | version = "0.27.0" 500 | source = "registry+https://github.com/rust-lang/crates.io-index" 501 | checksum = "d16546c5b5962abf8ce6e2881e722b4e0ae3b6f1a08a26ae3573c55853ca68d3" 502 | dependencies = [ 503 | "bitflags 2.6.0", 504 | "cassowary", 505 | "compact_str", 506 | "crossterm", 507 | "itertools", 508 | "lru", 509 | "paste", 510 | "stability", 511 | "strum", 512 | "strum_macros", 513 | "termion 4.0.3", 514 | "unicode-segmentation", 515 | "unicode-truncate", 516 | "unicode-width", 517 | ] 518 | 519 | [[package]] 520 | name = "redox_syscall" 521 | version = "0.2.16" 522 | source = "registry+https://github.com/rust-lang/crates.io-index" 523 | checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" 524 | dependencies = [ 525 | "bitflags 1.3.2", 526 | ] 527 | 528 | [[package]] 529 | name = "redox_syscall" 530 | version = "0.5.8" 531 | source = "registry+https://github.com/rust-lang/crates.io-index" 532 | checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" 533 | dependencies = [ 534 | "bitflags 2.6.0", 535 | ] 536 | 537 | [[package]] 538 | name = "redox_termios" 539 | version = "0.1.3" 540 | source = "registry+https://github.com/rust-lang/crates.io-index" 541 | checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb" 542 | 543 | [[package]] 544 | name = "redox_users" 545 | version = "0.4.6" 546 | source = "registry+https://github.com/rust-lang/crates.io-index" 547 | checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 548 | dependencies = [ 549 | "getrandom 0.2.15", 550 | "libredox", 551 | "thiserror", 552 | ] 553 | 554 | [[package]] 555 | name = "regex" 556 | version = "1.11.1" 557 | source = "registry+https://github.com/rust-lang/crates.io-index" 558 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 559 | dependencies = [ 560 | "aho-corasick", 561 | "memchr", 562 | "regex-automata", 563 | "regex-syntax", 564 | ] 565 | 566 | [[package]] 567 | name = "regex-automata" 568 | version = "0.4.9" 569 | source = "registry+https://github.com/rust-lang/crates.io-index" 570 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 571 | dependencies = [ 572 | "aho-corasick", 573 | "memchr", 574 | "regex-syntax", 575 | ] 576 | 577 | [[package]] 578 | name = "regex-syntax" 579 | version = "0.8.5" 580 | source = "registry+https://github.com/rust-lang/crates.io-index" 581 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 582 | 583 | [[package]] 584 | name = "rustversion" 585 | version = "1.0.19" 586 | source = "registry+https://github.com/rust-lang/crates.io-index" 587 | checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" 588 | 589 | [[package]] 590 | name = "ryu" 591 | version = "1.0.18" 592 | source = "registry+https://github.com/rust-lang/crates.io-index" 593 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 594 | 595 | [[package]] 596 | name = "scopeguard" 597 | version = "1.2.0" 598 | source = "registry+https://github.com/rust-lang/crates.io-index" 599 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 600 | 601 | [[package]] 602 | name = "serde" 603 | version = "1.0.217" 604 | source = "registry+https://github.com/rust-lang/crates.io-index" 605 | checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" 606 | dependencies = [ 607 | "serde_derive", 608 | ] 609 | 610 | [[package]] 611 | name = "serde_derive" 612 | version = "1.0.217" 613 | source = "registry+https://github.com/rust-lang/crates.io-index" 614 | checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" 615 | dependencies = [ 616 | "proc-macro2", 617 | "quote", 618 | "syn", 619 | ] 620 | 621 | [[package]] 622 | name = "serde_json" 623 | version = "1.0.134" 624 | source = "registry+https://github.com/rust-lang/crates.io-index" 625 | checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d" 626 | dependencies = [ 627 | "itoa", 628 | "memchr", 629 | "ryu", 630 | "serde", 631 | ] 632 | 633 | [[package]] 634 | name = "signal-hook" 635 | version = "0.3.17" 636 | source = "registry+https://github.com/rust-lang/crates.io-index" 637 | checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" 638 | dependencies = [ 639 | "libc", 640 | "signal-hook-registry", 641 | ] 642 | 643 | [[package]] 644 | name = "signal-hook-mio" 645 | version = "0.2.4" 646 | source = "registry+https://github.com/rust-lang/crates.io-index" 647 | checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" 648 | dependencies = [ 649 | "libc", 650 | "mio", 651 | "signal-hook", 652 | ] 653 | 654 | [[package]] 655 | name = "signal-hook-registry" 656 | version = "1.4.2" 657 | source = "registry+https://github.com/rust-lang/crates.io-index" 658 | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 659 | dependencies = [ 660 | "libc", 661 | ] 662 | 663 | [[package]] 664 | name = "smallvec" 665 | version = "1.13.2" 666 | source = "registry+https://github.com/rust-lang/crates.io-index" 667 | checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" 668 | 669 | [[package]] 670 | name = "stability" 671 | version = "0.2.1" 672 | source = "registry+https://github.com/rust-lang/crates.io-index" 673 | checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" 674 | dependencies = [ 675 | "quote", 676 | "syn", 677 | ] 678 | 679 | [[package]] 680 | name = "static_assertions" 681 | version = "1.1.0" 682 | source = "registry+https://github.com/rust-lang/crates.io-index" 683 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 684 | 685 | [[package]] 686 | name = "strsim" 687 | version = "0.11.1" 688 | source = "registry+https://github.com/rust-lang/crates.io-index" 689 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 690 | 691 | [[package]] 692 | name = "strum" 693 | version = "0.26.3" 694 | source = "registry+https://github.com/rust-lang/crates.io-index" 695 | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 696 | dependencies = [ 697 | "strum_macros", 698 | ] 699 | 700 | [[package]] 701 | name = "strum_macros" 702 | version = "0.26.4" 703 | source = "registry+https://github.com/rust-lang/crates.io-index" 704 | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 705 | dependencies = [ 706 | "heck", 707 | "proc-macro2", 708 | "quote", 709 | "rustversion", 710 | "syn", 711 | ] 712 | 713 | [[package]] 714 | name = "syn" 715 | version = "2.0.93" 716 | source = "registry+https://github.com/rust-lang/crates.io-index" 717 | checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058" 718 | dependencies = [ 719 | "proc-macro2", 720 | "quote", 721 | "unicode-ident", 722 | ] 723 | 724 | [[package]] 725 | name = "termion" 726 | version = "1.5.6" 727 | source = "registry+https://github.com/rust-lang/crates.io-index" 728 | checksum = "077185e2eac69c3f8379a4298e1e07cd36beb962290d4a51199acf0fdc10607e" 729 | dependencies = [ 730 | "libc", 731 | "numtoa 0.1.0", 732 | "redox_syscall 0.2.16", 733 | "redox_termios", 734 | ] 735 | 736 | [[package]] 737 | name = "termion" 738 | version = "4.0.3" 739 | source = "registry+https://github.com/rust-lang/crates.io-index" 740 | checksum = "7eaa98560e51a2cf4f0bb884d8b2098a9ea11ecf3b7078e9c68242c74cc923a7" 741 | dependencies = [ 742 | "libc", 743 | "libredox", 744 | "numtoa 0.2.4", 745 | "redox_termios", 746 | ] 747 | 748 | [[package]] 749 | name = "thiserror" 750 | version = "1.0.69" 751 | source = "registry+https://github.com/rust-lang/crates.io-index" 752 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 753 | dependencies = [ 754 | "thiserror-impl", 755 | ] 756 | 757 | [[package]] 758 | name = "thiserror-impl" 759 | version = "1.0.69" 760 | source = "registry+https://github.com/rust-lang/crates.io-index" 761 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 762 | dependencies = [ 763 | "proc-macro2", 764 | "quote", 765 | "syn", 766 | ] 767 | 768 | [[package]] 769 | name = "unicode-ident" 770 | version = "1.0.14" 771 | source = "registry+https://github.com/rust-lang/crates.io-index" 772 | checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" 773 | 774 | [[package]] 775 | name = "unicode-segmentation" 776 | version = "1.12.0" 777 | source = "registry+https://github.com/rust-lang/crates.io-index" 778 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 779 | 780 | [[package]] 781 | name = "unicode-truncate" 782 | version = "1.1.0" 783 | source = "registry+https://github.com/rust-lang/crates.io-index" 784 | checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" 785 | dependencies = [ 786 | "itertools", 787 | "unicode-segmentation", 788 | "unicode-width", 789 | ] 790 | 791 | [[package]] 792 | name = "unicode-width" 793 | version = "0.1.14" 794 | source = "registry+https://github.com/rust-lang/crates.io-index" 795 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 796 | 797 | [[package]] 798 | name = "utf8parse" 799 | version = "0.2.2" 800 | source = "registry+https://github.com/rust-lang/crates.io-index" 801 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 802 | 803 | [[package]] 804 | name = "wasi" 805 | version = "0.9.0+wasi-snapshot-preview1" 806 | source = "registry+https://github.com/rust-lang/crates.io-index" 807 | checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" 808 | 809 | [[package]] 810 | name = "wasi" 811 | version = "0.11.0+wasi-snapshot-preview1" 812 | source = "registry+https://github.com/rust-lang/crates.io-index" 813 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 814 | 815 | [[package]] 816 | name = "winapi" 817 | version = "0.3.9" 818 | source = "registry+https://github.com/rust-lang/crates.io-index" 819 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 820 | dependencies = [ 821 | "winapi-i686-pc-windows-gnu", 822 | "winapi-x86_64-pc-windows-gnu", 823 | ] 824 | 825 | [[package]] 826 | name = "winapi-i686-pc-windows-gnu" 827 | version = "0.4.0" 828 | source = "registry+https://github.com/rust-lang/crates.io-index" 829 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 830 | 831 | [[package]] 832 | name = "winapi-x86_64-pc-windows-gnu" 833 | version = "0.4.0" 834 | source = "registry+https://github.com/rust-lang/crates.io-index" 835 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 836 | 837 | [[package]] 838 | name = "windows-sys" 839 | version = "0.48.0" 840 | source = "registry+https://github.com/rust-lang/crates.io-index" 841 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 842 | dependencies = [ 843 | "windows-targets 0.48.5", 844 | ] 845 | 846 | [[package]] 847 | name = "windows-sys" 848 | version = "0.59.0" 849 | source = "registry+https://github.com/rust-lang/crates.io-index" 850 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 851 | dependencies = [ 852 | "windows-targets 0.52.6", 853 | ] 854 | 855 | [[package]] 856 | name = "windows-targets" 857 | version = "0.48.5" 858 | source = "registry+https://github.com/rust-lang/crates.io-index" 859 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 860 | dependencies = [ 861 | "windows_aarch64_gnullvm 0.48.5", 862 | "windows_aarch64_msvc 0.48.5", 863 | "windows_i686_gnu 0.48.5", 864 | "windows_i686_msvc 0.48.5", 865 | "windows_x86_64_gnu 0.48.5", 866 | "windows_x86_64_gnullvm 0.48.5", 867 | "windows_x86_64_msvc 0.48.5", 868 | ] 869 | 870 | [[package]] 871 | name = "windows-targets" 872 | version = "0.52.6" 873 | source = "registry+https://github.com/rust-lang/crates.io-index" 874 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 875 | dependencies = [ 876 | "windows_aarch64_gnullvm 0.52.6", 877 | "windows_aarch64_msvc 0.52.6", 878 | "windows_i686_gnu 0.52.6", 879 | "windows_i686_gnullvm", 880 | "windows_i686_msvc 0.52.6", 881 | "windows_x86_64_gnu 0.52.6", 882 | "windows_x86_64_gnullvm 0.52.6", 883 | "windows_x86_64_msvc 0.52.6", 884 | ] 885 | 886 | [[package]] 887 | name = "windows_aarch64_gnullvm" 888 | version = "0.48.5" 889 | source = "registry+https://github.com/rust-lang/crates.io-index" 890 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 891 | 892 | [[package]] 893 | name = "windows_aarch64_gnullvm" 894 | version = "0.52.6" 895 | source = "registry+https://github.com/rust-lang/crates.io-index" 896 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 897 | 898 | [[package]] 899 | name = "windows_aarch64_msvc" 900 | version = "0.48.5" 901 | source = "registry+https://github.com/rust-lang/crates.io-index" 902 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 903 | 904 | [[package]] 905 | name = "windows_aarch64_msvc" 906 | version = "0.52.6" 907 | source = "registry+https://github.com/rust-lang/crates.io-index" 908 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 909 | 910 | [[package]] 911 | name = "windows_i686_gnu" 912 | version = "0.48.5" 913 | source = "registry+https://github.com/rust-lang/crates.io-index" 914 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 915 | 916 | [[package]] 917 | name = "windows_i686_gnu" 918 | version = "0.52.6" 919 | source = "registry+https://github.com/rust-lang/crates.io-index" 920 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 921 | 922 | [[package]] 923 | name = "windows_i686_gnullvm" 924 | version = "0.52.6" 925 | source = "registry+https://github.com/rust-lang/crates.io-index" 926 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 927 | 928 | [[package]] 929 | name = "windows_i686_msvc" 930 | version = "0.48.5" 931 | source = "registry+https://github.com/rust-lang/crates.io-index" 932 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 933 | 934 | [[package]] 935 | name = "windows_i686_msvc" 936 | version = "0.52.6" 937 | source = "registry+https://github.com/rust-lang/crates.io-index" 938 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 939 | 940 | [[package]] 941 | name = "windows_x86_64_gnu" 942 | version = "0.48.5" 943 | source = "registry+https://github.com/rust-lang/crates.io-index" 944 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 945 | 946 | [[package]] 947 | name = "windows_x86_64_gnu" 948 | version = "0.52.6" 949 | source = "registry+https://github.com/rust-lang/crates.io-index" 950 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 951 | 952 | [[package]] 953 | name = "windows_x86_64_gnullvm" 954 | version = "0.48.5" 955 | source = "registry+https://github.com/rust-lang/crates.io-index" 956 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 957 | 958 | [[package]] 959 | name = "windows_x86_64_gnullvm" 960 | version = "0.52.6" 961 | source = "registry+https://github.com/rust-lang/crates.io-index" 962 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 963 | 964 | [[package]] 965 | name = "windows_x86_64_msvc" 966 | version = "0.48.5" 967 | source = "registry+https://github.com/rust-lang/crates.io-index" 968 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 969 | 970 | [[package]] 971 | name = "windows_x86_64_msvc" 972 | version = "0.52.6" 973 | source = "registry+https://github.com/rust-lang/crates.io-index" 974 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 975 | 976 | [[package]] 977 | name = "zerocopy" 978 | version = "0.7.35" 979 | source = "registry+https://github.com/rust-lang/crates.io-index" 980 | checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" 981 | dependencies = [ 982 | "byteorder", 983 | "zerocopy-derive", 984 | ] 985 | 986 | [[package]] 987 | name = "zerocopy-derive" 988 | version = "0.7.35" 989 | source = "registry+https://github.com/rust-lang/crates.io-index" 990 | checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" 991 | dependencies = [ 992 | "proc-macro2", 993 | "quote", 994 | "syn", 995 | ] 996 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bookmark" 3 | version = "0.2.3" 4 | authors = ["Szymongib "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [lib] 10 | name = "bookmark_lib" 11 | path = "src/lib/lib.rs" 12 | 13 | [[bin]] 14 | name = "bookmark" 15 | path = "src/bin/main.rs" 16 | 17 | [dependencies] 18 | clap = { version = "4.5", features = ["string"] } 19 | serde = { version = "1.0", features = ["derive"] } 20 | serde_json = "1.0" 21 | dirs = "2.0.0" 22 | ratatui = { version = "0.27", features = ["termion"] } 23 | termion = "1.5.5" 24 | open = "1" 25 | hex = "0.4" 26 | rand = "0.7.3" 27 | regex = "1" 28 | 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Szymon Gibała 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # Bookmark 6 | 7 | ![Build and test workflow](https://github.com/Szymongib/bookmark/workflows/Build%20And%20Test/badge.svg?branch=master) 8 | 9 | Bookmark allows you to save your favourite URLs without leaving the terminal and then quickly open them in the browser. 10 | 11 | ![Bookmark - Demo](./assets/bookmark-demo.gif) 12 | 13 | 14 | ## Installation 15 | 16 | > **CAUTION:** It is recommended to use released version. Use master version on your own risk. There might be breaking changes or experimental features. 17 | 18 | ### Released version 19 | 20 | Download for Linux: 21 | ```bash 22 | wget https://github.com/Szymongib/bookmark/releases/download/v0.2.3/bookmark-linux-amd64 23 | 24 | chmod +x bookmark-linux-amd64 25 | sudo mv bookmark-linux-amd64 /usr/local/bin/bookmark 26 | ``` 27 | 28 | Download for Mac OS: 29 | ```bash 30 | wget https://github.com/Szymongib/bookmark/releases/download/v0.2.3/bookmark-darwin-x86_64 31 | 32 | chmod +x bookmark-darwin-x86_64 33 | sudo mv bookmark-darwin-x86_64 /usr/local/bin/bookmark 34 | ``` 35 | 36 | 37 | ### Using git and Cargo 38 | 39 | ```bash 40 | git clone git@github.com:Szymongib/bookmark.git 41 | ``` 42 | ```bash 43 | cd bookmark 44 | ``` 45 | ```bash 46 | cargo install --path . 47 | ``` 48 | 49 | 50 | ## Usage 51 | 52 | > **NOTE:** For correct usage documentation, check documentation from tag for corresponding version. 53 | 54 | Example commands: 55 | 56 | Add URL: 57 | ```bash 58 | bookmark add GitHub https://github.com 59 | ``` 60 | 61 | Enter interactive mode: 62 | ```bash 63 | bookmark 64 | ``` 65 | Use `enter` to open URL in the browser, `q` to quite the interactive mode and `h` to display help panel. 66 | 67 | 68 | List URLs: 69 | ```bash 70 | bookmark ls 71 | ``` 72 | 73 | For complete usage of both the Interactive mode and the Standard mode, checkout [the usage documentation.](./docs/usage.md) 74 | 75 | 76 | ## Migrate to new version 77 | 78 | If you used Bookmark in version `v0.0.x` you can import your bookmarks to `v0.1.x`. 79 | To see how to do it, checkout [the documentation.](./docs/import-from-previous-version.md) 80 | 81 | ## Groups and tags 82 | 83 | URLs can be added to groups and labeled with tag. Some groups and tags principles include: 84 | - Every URL can be in a single group. 85 | - Every URL can have multiple tags. 86 | - URL names in scope of one group have to be unique. 87 | 88 | Some things to consider when using groups and tags: 89 | - If the group is not specified when **adding** the URL, the `default` group is used. 90 | - If the group is not specified when **listing** URLs, all groups are listed. 91 | - If multiple tags are specified when **listing** URLs, all URLs matching at least one tag are listed. 92 | 93 | Use `-g [GROUP_NAME]` flag to add or list URLs from a specified group. 94 | Use `-t [TAG_NAME]` flag/flags to add or list URLs with specified tags. 95 | -------------------------------------------------------------------------------- /assets/bookmark-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Szymongib/bookmark/1ef5fd0b877e7a83c6dca37a3fe561bc724b9a66/assets/bookmark-demo.gif -------------------------------------------------------------------------------- /assets/bookmark_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Szymongib/bookmark/1ef5fd0b877e7a83c6dca37a3fe561bc724b9a66/assets/bookmark_logo.png -------------------------------------------------------------------------------- /assets/bookmark_logo_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Szymongib/bookmark/1ef5fd0b877e7a83c6dca37a3fe561bc724b9a66/assets/bookmark_logo_text.png -------------------------------------------------------------------------------- /docs/import-from-previous-version.md: -------------------------------------------------------------------------------- 1 | # Import from previous version 2 | 3 | The structure of bookmarks data can change between certain versions. 4 | For example between versions `v0.0.x` and `v0.1.x`. 5 | 6 | To make it easy to update between versions, Bookmark introduces `import` command that should correctly move old bookmarks to be compatible with the new version. 7 | 8 | If you are using default file paths to store bookmarks, simply run: 9 | ```bash 10 | bookmark import 11 | ``` 12 | 13 | In a case that you used custom file, you can specify it with the `--old-file` flag: 14 | ```bash 15 | bookmark import --old-file /home/my-old-bookmark-file.json 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | Bookmark can be used in one of two modes: 4 | - Interactive mode 5 | - Standard CLI mode 6 | 7 | ## Interactive mode 8 | 9 | Interactive mode opens simple user interface in the terminal, allowing user to open, delete and modify bookmarks. 10 | To open interactive mode, simply run: 11 | ```bash 12 | bookmark 13 | ``` 14 | 15 | ### Controls 16 | 17 | To display list of available controls, press `h` while in the interactive mode. 18 | 19 | Other controls include: 20 | 21 | | Key | Action | 22 | |:-------:|:------:| 23 | | `ENTER` | Opens bookmarked URL in default browser | 24 | | `/` or `CTRL + f` | Starts bookmark search | 25 | | `h` | Shows/Hides the help panel | 26 | | `d` | Deletes URL (confirmation needed) | 27 | | `i` | Shows/Hides bookmark ids | 28 | | `q` | Exits interactive mode | 29 | | `:` | Enters command input mode | 30 | 31 | ### Commands 32 | 33 | Command input mode can be enabled with the `:` while in the interactive mode. 34 | 35 | Commands will be applied to the currently selected bookmark in the table. 36 | 37 | The commands names are similar to those in the Standard mode: 38 | 39 | | Command | Arguments | Action | 40 | |:-------:|:---------:|:------:| 41 | | `tag` | [TAG_NAME] | Adds tag to the bookmark | 42 | | `untag` | [TAG_NAME] | Removes tag from the bookmark | 43 | | `chg` | [NEW_GROUP] | Changes group of the bookmark | 44 | | `chn` | [NEW_NAME] | Changes name of the bookmark | 45 | | `chu` | [NEW_URL] | Changes URL of the bookmark | 46 | | `q` | - | Exits interactive mode | 47 | 48 | 49 | ## Standard mode 50 | 51 | In standard mode use commands directly in the terminal following the pattern: 52 | ```bash 53 | bookmark [COMMAND] [OPTS] [ARGS] 54 | ``` 55 | For example to list all bookmarks in group `dev`, run: 56 | ```bash 57 | bookmark list -g dev 58 | ``` 59 | 60 | ### Commands 61 | 62 | To see available commands together with the description, run: 63 | ```bash 64 | bookmark -h 65 | ``` 66 | To see options and arguments of the specific command, run: 67 | ```bash 68 | bookmark [COMMAND] -h 69 | ``` 70 | -------------------------------------------------------------------------------- /src/bin/cmd/mod.rs: -------------------------------------------------------------------------------- 1 | pub const GROUP_SUB_CMD: &str = "group"; 2 | pub const GROUP_LIST_CMD: &str = "list"; 3 | 4 | pub const ADD_SUB_CMD: &str = "add"; 5 | pub const LIST_SUB_CMD: &str = "list"; 6 | pub const DELETE_SUB_CMD: &str = "delete"; 7 | pub const TAG_SUB_CMD: &str = "tag"; 8 | pub const UNTAG_SUB_CMD: &str = "untag"; 9 | pub const IMPORT_SUB_CMD: &str = "import"; 10 | pub const CHANGE_GROUP_SUB_CMD: &str = "chgroup"; 11 | pub const CHANGE_GROUP_SUB_CMD_ALIAS: &str = "chg"; 12 | pub const CHANGE_NAME_SUB_CMD: &str = "chn"; 13 | pub const CHANGE_NAME_SUB_CMD_ALIAS: &str = "chname"; 14 | pub const CHANGE_URL_SUB_CMD: &str = "chu"; 15 | pub const CHANGE_URL_SUB_CMD_ALIAS: &str = "churl"; 16 | pub const SORT_CMD: &str = "sort"; 17 | -------------------------------------------------------------------------------- /src/bin/display/mod.rs: -------------------------------------------------------------------------------- 1 | use bookmark_lib::types::URLRecord; 2 | 3 | pub(crate) fn display_urls(urls: Vec) { 4 | println!("{}", display_str(urls)) 5 | } 6 | 7 | fn display_str(urls: Vec) -> String { 8 | let (name_len, url_len, group_len, tags_len) = get_max_lengths(&urls); 9 | let id_len = if !urls.is_empty() { 10 | urls[0].id.len() // Ids have uniform length 11 | } else { 12 | 0 13 | }; 14 | 15 | let mut out = header(id_len, name_len, url_len, group_len, tags_len); 16 | out.push('\n'); 17 | 18 | for u in urls { 19 | out.push_str(&format!( 20 | "\n{} {} {} {} {}", 21 | pad(u.id.clone(), id_len), 22 | pad(u.name.clone(), name_len), 23 | pad(u.url.clone(), url_len), 24 | pad(u.group.clone(), group_len), 25 | pad(u.tags_as_string(), tags_len) 26 | )) 27 | } 28 | 29 | out 30 | } 31 | 32 | fn header( 33 | id_len: usize, 34 | name_len: usize, 35 | url_len: usize, 36 | group_len: usize, 37 | tags_len: usize, 38 | ) -> String { 39 | let id = pad("Id".to_string(), id_len); 40 | let name = pad("Name".to_string(), name_len); 41 | let url = pad("URL".to_string(), url_len); 42 | let group = pad("Group".to_string(), group_len); 43 | let tags = pad("Tags".to_string(), tags_len); 44 | 45 | format!("{} {} {} {} {}", id, name, url, group, tags) 46 | } 47 | 48 | fn pad(s: String, len: usize) -> String { 49 | let mut s = s; 50 | 51 | let pad_count = if len >= s.len() { len - s.len() } else { 0 }; 52 | 53 | for _ in 0..pad_count { 54 | s.push(' '); 55 | } 56 | s 57 | } 58 | 59 | /// Returns max length of Name, URL, Group, Tags 60 | fn get_max_lengths(urls: &[URLRecord]) -> (usize, usize, usize, usize) { 61 | let mut max_len: [usize; 4] = [4, 3, 5, 0]; 62 | 63 | for u in urls { 64 | if u.name.len() > max_len[0] { 65 | max_len[0] = u.name.len() 66 | } 67 | if u.url.len() > max_len[1] { 68 | max_len[1] = u.url.len() 69 | } 70 | if u.group.len() > max_len[2] { 71 | max_len[2] = u.group.len() 72 | } 73 | let tags_len = u.tags_as_string().len(); 74 | if tags_len > max_len[3] { 75 | max_len[3] = tags_len 76 | } 77 | } 78 | 79 | (max_len[0], max_len[1], max_len[2], max_len[3]) 80 | } 81 | 82 | #[cfg(test)] 83 | mod test { 84 | use crate::display::display_str; 85 | use bookmark_lib::types::URLRecord; 86 | 87 | struct TestCase { 88 | description: String, 89 | records: Vec, 90 | expected_lines: Vec, 91 | } 92 | 93 | #[test] 94 | fn test_display_str() { 95 | let records = vec![ 96 | URLRecord::new("https://one_long_url.com", "one_name", "one", vec!["tag"]), 97 | URLRecord::new( 98 | "two", 99 | "two long name wow such name", 100 | "two_long_group", 101 | Vec::::new(), 102 | ), 103 | URLRecord::new("three", "three", "three", Vec::::new()), 104 | URLRecord::new( 105 | "four.com", 106 | "four mid len", 107 | "4", 108 | vec!["tag", "other-tag", "yet-one-more", "with space"], 109 | ), 110 | URLRecord::new( 111 | "five", 112 | "five", 113 | "five", 114 | vec!["just_one_but_long_tag_much_wow"], 115 | ), 116 | ]; 117 | 118 | let single_record = URLRecord::new( 119 | "https://httpbin.org", 120 | "HTTP Bin", 121 | "default", 122 | vec!["testing"], 123 | ); 124 | 125 | let test_cases = vec![ 126 | TestCase{ 127 | description: "Several URL records".to_string(), 128 | records: records.clone(), 129 | expected_lines: vec![ 130 | "Id Name URL Group Tags ".to_string(), 131 | "".to_string(), 132 | format!("{} one_name https://one_long_url.com one tag ", records[0].id), 133 | format!("{} two long name wow such name two two_long_group ", records[1].id), 134 | format!("{} three three three ", records[2].id), 135 | format!("{} four mid len four.com 4 other-tag, tag, \"with space\", yet-one-more", records[3].id), 136 | format!("{} five five five just_one_but_long_tag_much_wow ", records[4].id), 137 | ], 138 | }, 139 | TestCase{ 140 | description: "Single URL record".to_string(), 141 | records: vec![single_record.clone()], 142 | expected_lines: vec![ 143 | "Id Name URL Group Tags ".to_string(), 144 | "".to_string(), 145 | format!("{} HTTP Bin https://httpbin.org default testing", single_record.id), 146 | ], 147 | }, 148 | TestCase{ 149 | description: "No URL records".to_string(), 150 | records: vec![], 151 | expected_lines: vec![ 152 | "Id Name URL Group Tags".to_string(), 153 | "".to_string(), 154 | ], 155 | } 156 | ]; 157 | 158 | for test in test_cases { 159 | println!("Test: {}", test.description); 160 | let display = display_str(test.records); 161 | 162 | let lines: Vec<&str> = display.split("\n").collect(); 163 | for i in 0..lines.len() { 164 | assert_eq!(lines[i], test.expected_lines[i]) 165 | } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/bin/interactive/bookmarks_table.rs: -------------------------------------------------------------------------------- 1 | use crate::interactive::event::Event; 2 | use crate::interactive::event::Signal; 3 | use crate::interactive::table::{StatefulTable, TableItem}; 4 | use crate::interactive::url_table_item::{default_columns, Columns, URLItem}; 5 | use bookmark_lib::filters::{Filter, UnorderedWordSetFilter}; 6 | use bookmark_lib::types::URLRecord; 7 | use bookmark_lib::Registry; 8 | use std::sync::mpsc; 9 | use termion::event::Key; 10 | 11 | use crate::cmd; 12 | use bookmark_lib::sort::{SortBy, SortConfig}; 13 | use std::str::FromStr; 14 | 15 | type CommandResult = Result<(), Box>; 16 | 17 | pub struct BookmarksTable { 18 | signal_sender: mpsc::Sender>, 19 | registry: Box, 20 | table: StatefulTable, 21 | columns: Vec, 22 | filter: Option>, 23 | sort_cfg: Option, 24 | } 25 | 26 | impl BookmarksTable { 27 | pub fn next(&mut self) { 28 | self.table.next() 29 | } 30 | 31 | pub fn previous(&mut self) { 32 | self.table.previous() 33 | } 34 | 35 | pub fn unselect(&mut self) { 36 | self.table.unselect() 37 | } 38 | 39 | pub fn table(&mut self) -> &mut StatefulTable { 40 | &mut self.table 41 | } 42 | 43 | pub fn columns(&self) -> &Columns { 44 | &self.columns 45 | } 46 | 47 | pub fn get_selected(&self) -> Result, Box> { 48 | let selected_id = self.get_selected_id(); 49 | if selected_id.is_none() { 50 | return Ok(None); 51 | } 52 | 53 | let url_record = self.registry.get_url(&selected_id.unwrap())?; 54 | 55 | Ok(url_record) 56 | } 57 | 58 | pub fn open(&self) -> Result<(), Box> { 59 | match self.table.state.selected() { 60 | Some(id) => match open::that(self.table.items[id].url().as_str()) { 61 | Ok(_) => Ok(()), 62 | Err(err) => Err(From::from(format!( 63 | "failed to open URL in the browser: {}", 64 | err.to_string() 65 | ))), 66 | }, 67 | None => Ok(()), 68 | } 69 | } 70 | 71 | pub fn search(&mut self, phrase: &str) -> Result<(), Box> { 72 | self.filter = Some(Box::new(UnorderedWordSetFilter::new(phrase))); 73 | self.refresh_items() 74 | } 75 | 76 | pub fn set_columns(&mut self, columns: Columns) -> Result<(), Box> { 77 | self.columns = columns; 78 | self.refresh_items() 79 | } 80 | 81 | // TODO: consider returning some command result 82 | pub fn exec( 83 | &mut self, 84 | command: &str, 85 | args: Vec<&str>, 86 | ) -> Result<(), Box> { 87 | let id = self.get_selected_id(); 88 | 89 | match command { 90 | cmd::TAG_SUB_CMD => self.tag(id, args)?, 91 | cmd::UNTAG_SUB_CMD => self.untag(id, args)?, 92 | cmd::CHANGE_GROUP_SUB_CMD | cmd::CHANGE_GROUP_SUB_CMD_ALIAS => { 93 | self.change_group(id, args)? 94 | } 95 | cmd::CHANGE_NAME_SUB_CMD | cmd::CHANGE_NAME_SUB_CMD_ALIAS => { 96 | self.change_name(id, args)? 97 | } 98 | cmd::CHANGE_URL_SUB_CMD | cmd::CHANGE_URL_SUB_CMD_ALIAS => self.change_url(id, args)?, 99 | cmd::SORT_CMD => self.sort_urls(id, args)?, 100 | "q" | "quit" => self.signal_sender.send(Event::Signal(Signal::Quit))?, 101 | _ => return Err(From::from(format!("error: command {} not found", command))), 102 | }; 103 | 104 | self.refresh_items()?; 105 | 106 | Ok(()) 107 | } 108 | 109 | pub fn tag(&mut self, id: Option, args: Vec<&str>) -> CommandResult { 110 | let id = unwrap_id(id)?; 111 | 112 | if args.is_empty() { 113 | return Err(From::from( 114 | "tag requires exactly one argument. Usage: tag [TAG_1]", 115 | )); // TODO: support multiple tags at once 116 | } 117 | 118 | self.registry.tag(&id, args[0])?; 119 | Ok(()) 120 | } 121 | 122 | pub fn untag(&mut self, id: Option, args: Vec<&str>) -> CommandResult { 123 | let id = unwrap_id(id)?; 124 | 125 | if args.is_empty() { 126 | return Err(From::from( 127 | "untag requires exactly one argument. Usage: untag [TAG_1]", 128 | )); // TODO: support multiple tags at once 129 | } 130 | 131 | self.registry.untag(&id, args[0])?; 132 | Ok(()) 133 | } 134 | 135 | pub fn change_group(&mut self, id: Option, args: Vec<&str>) -> CommandResult { 136 | let id = unwrap_id(id)?; 137 | 138 | if args.is_empty() { 139 | return Err(From::from( 140 | "change group requires exactly one argument. Usage: chg [GROUP]", 141 | )); 142 | } 143 | 144 | self.registry.change_group(&id, args[0])?; 145 | Ok(()) 146 | } 147 | 148 | pub fn change_name(&mut self, id: Option, args: Vec<&str>) -> CommandResult { 149 | let id = unwrap_id(id)?; 150 | 151 | if args.is_empty() { 152 | return Err(From::from( 153 | "change name requires exactly one argument. Usage: chn [NAME]", 154 | )); 155 | } 156 | 157 | self.registry.change_name(&id, args[0])?; 158 | Ok(()) 159 | } 160 | 161 | pub fn change_url(&mut self, id: Option, args: Vec<&str>) -> CommandResult { 162 | let id = unwrap_id(id)?; 163 | 164 | if args.is_empty() { 165 | return Err(From::from( 166 | "change url requires exactly one argument. Usage: chu [URL]", 167 | )); 168 | } 169 | 170 | self.registry.change_url(&id, args[0])?; 171 | Ok(()) 172 | } 173 | 174 | pub fn sort_urls(&mut self, _: Option, args: Vec<&str>) -> CommandResult { 175 | let sort_cfg = if args.is_empty() { 176 | SortConfig::new_by(SortBy::Name) 177 | } else { 178 | let sort_by = SortBy::from_str(&args[0])?; 179 | SortConfig::new_by(sort_by) 180 | }; 181 | self.sort_cfg = Some(sort_cfg); 182 | 183 | self.refresh_items() 184 | } 185 | 186 | pub fn delete(&mut self) -> Result> { 187 | match self.get_selected_id() { 188 | Some(id) => { 189 | if self.registry.delete(&id)? { 190 | self.refresh_items()?; 191 | return Ok(true); 192 | } 193 | Ok(false) 194 | } 195 | None => Ok(false), 196 | } 197 | } 198 | 199 | fn refresh_items(&mut self) -> Result<(), Box> { 200 | let urls = match &self.filter { 201 | Some(f) => self.registry.list_urls(Some(f.as_ref()), self.sort_cfg)?, 202 | None => self.registry.list_urls(None, self.sort_cfg)?, 203 | }; 204 | 205 | self.table 206 | .override_items(URLItem::from_vec(urls, Some(&self.columns))); 207 | Ok(()) 208 | } 209 | 210 | fn get_selected_id(&self) -> Option { 211 | self.table 212 | .state 213 | .selected() 214 | .map(|index| self.table.items[index].id()) 215 | } 216 | } 217 | 218 | impl BookmarksTable { 219 | pub fn new( 220 | sender: mpsc::Sender>, 221 | registry: Box, 222 | ) -> Result> { 223 | let default_columns = default_columns(); 224 | 225 | let items: Vec = 226 | URLItem::from_vec(registry.list_urls(None, None)?, Some(&default_columns)); 227 | let table = StatefulTable::with_items(items); 228 | 229 | Ok(BookmarksTable { 230 | signal_sender: sender, 231 | registry, 232 | table, 233 | filter: None, 234 | sort_cfg: None, 235 | columns: default_columns, 236 | }) 237 | } 238 | } 239 | 240 | fn unwrap_id(id: Option) -> Result> { 241 | match id { 242 | Some(id) => Ok(id), 243 | None => Err(From::from("error: item not selected".to_string())), 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/bin/interactive/event.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::sync::mpsc; 3 | use std::thread; 4 | 5 | use termion::event::Key; 6 | use termion::input::TermRead; 7 | 8 | #[derive(Clone)] 9 | pub enum Event { 10 | Input(I), 11 | Signal(Signal), 12 | } 13 | 14 | #[derive(Clone)] 15 | pub enum Signal { 16 | Quit, 17 | } 18 | 19 | pub struct Events { 20 | pub tx: mpsc::Sender>, 21 | rx: mpsc::Receiver>, 22 | _input_handle: thread::JoinHandle<()>, 23 | } 24 | 25 | #[derive(Debug, Clone, Copy)] 26 | pub struct Config {} 27 | 28 | impl Default for Config { 29 | fn default() -> Config { 30 | Config {} 31 | } 32 | } 33 | 34 | impl Events { 35 | pub fn new() -> Events { 36 | Events::with_config(Config::default()) 37 | } 38 | 39 | pub fn with_config(_config: Config) -> Events { 40 | let (tx, rx) = mpsc::channel(); 41 | let input_handle = { 42 | let tx = tx.clone(); 43 | thread::spawn(move || { 44 | let stdin = io::stdin(); 45 | for evt in stdin.keys() { 46 | if let Ok(key) = evt { 47 | if let Err(err) = tx.send(Event::Input(key)) { 48 | eprintln!("{}", err); 49 | return; 50 | } 51 | } 52 | } 53 | }) 54 | }; 55 | Events { 56 | tx, 57 | rx, 58 | _input_handle: input_handle, 59 | } 60 | } 61 | 62 | pub fn next(&self) -> Result, mpsc::RecvError> { 63 | self.rx.recv() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/bin/interactive/helpers.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::{Constraint, Direction, Layout}; 2 | 3 | // TODO: consider moving to some lib 4 | macro_rules! hashmap { 5 | ($( $key: expr => $val: expr ),*) => {{ 6 | let mut map = ::std::collections::HashMap::new(); 7 | $( map.insert($key, $val); )* 8 | map 9 | }} 10 | } 11 | 12 | pub fn vertical_layout(heights: Vec) -> Layout { 13 | let constraints = to_constraints(heights); 14 | 15 | Layout::default() 16 | .direction(Direction::Vertical) 17 | .constraints(constraints) 18 | } 19 | 20 | pub fn horizontal_layout(widths: Vec) -> Layout { 21 | let constraints = to_constraints(widths); 22 | 23 | Layout::default() 24 | .direction(Direction::Horizontal) 25 | .constraints(constraints) 26 | } 27 | 28 | fn to_constraints(vals: Vec) -> Vec { 29 | vals.iter().map(|h| Constraint::Length(*h)).collect() 30 | } 31 | 32 | pub fn to_string(vec: Vec<&str>) -> Vec { 33 | vec.iter().map(|s| s.to_string()).collect() 34 | } 35 | -------------------------------------------------------------------------------- /src/bin/interactive/interactive_mode.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{backend::TermionBackend, Terminal}; 2 | use std::{error::Error, io}; 3 | use termion::{input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen}; 4 | 5 | use super::event::Events; 6 | 7 | use crate::interactive::bookmarks_table::BookmarksTable; 8 | use crate::interactive::interface::Interface; 9 | use bookmark_lib::Registry; 10 | 11 | pub fn enter_interactive_mode(registry: T) -> Result<(), Box> { 12 | let stdout = io::stdout().into_raw_mode()?; 13 | let stdout = MouseTerminal::from(stdout); 14 | let stdout = AlternateScreen::from(stdout); 15 | let backend = TermionBackend::new(stdout); 16 | let mut terminal = Terminal::new(backend)?; 17 | terminal.hide_cursor()?; 18 | 19 | let events = Events::new(); 20 | 21 | let bookmarks_table = BookmarksTable::new(events.tx.clone(), Box::new(registry)); 22 | 23 | let mut user_interface = Interface::new(bookmarks_table?)?; 24 | 25 | loop { 26 | terminal.draw(|f| user_interface.draw(f))?; 27 | 28 | let quit = user_interface.handle_input(events.next()?)?; 29 | if quit { 30 | return Ok(()); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/bin/interactive/interface.rs: -------------------------------------------------------------------------------- 1 | use crate::interactive::bookmarks_table::BookmarksTable; 2 | use crate::interactive::event::{Event, Signal}; 3 | use crate::interactive::helpers::to_string; 4 | use crate::interactive::modules::command::Command; 5 | use crate::interactive::modules::delete::Delete; 6 | use crate::interactive::modules::help::HelpPanel; 7 | use crate::interactive::modules::search::Search; 8 | use crate::interactive::modules::Module; 9 | use crate::interactive::table::TableItem; 10 | use crate::interactive::url_table_item::default_columns; 11 | use ratatui::layout::{Constraint, Direction, Layout}; 12 | use ratatui::style::{Color, Modifier, Style}; 13 | use ratatui::text::Text; 14 | use ratatui::widgets::{Block, Borders, Row, Table}; 15 | use ratatui::Frame; 16 | use std::collections::HashMap; 17 | use std::error::Error; 18 | use termion::event::Key; 19 | 20 | #[derive(PartialEq, Eq, Hash, Clone)] 21 | pub enum InputMode { 22 | Normal, 23 | Search, 24 | Command, 25 | Suppressed(SuppressedAction), 26 | } 27 | 28 | #[derive(PartialEq, Eq, Hash, Clone)] 29 | pub enum SuppressedAction { 30 | ShowHelp, 31 | Delete, 32 | } 33 | 34 | pub struct Interface { 35 | bookmarks_table: BookmarksTable, 36 | 37 | /// Interface modules 38 | modules: HashMap>, 39 | 40 | /// Current mode of input 41 | input_mode: InputMode, 42 | 43 | /// Styles used for displaying user interface 44 | styles: Styles, 45 | 46 | cols_constraints: Vec, 47 | 48 | display_ids: bool, 49 | } 50 | 51 | struct Styles { 52 | normal: Style, 53 | selected: Style, 54 | header: Style, 55 | } 56 | 57 | impl Interface { 58 | pub(crate) fn new( 59 | bookmarks_table: BookmarksTable, 60 | ) -> Result> { 61 | let search_mod: Box = Box::new(Search::new()); 62 | let help_mod: Box = Box::new(HelpPanel::new()); 63 | let delete_mod: Box = Box::new(Delete::new()); 64 | let command_mod: Box = Box::new(Command::new()?); 65 | 66 | Ok(Interface { 67 | bookmarks_table, 68 | input_mode: InputMode::Normal, 69 | modules: hashmap![ 70 | InputMode::Search => search_mod, 71 | InputMode::Suppressed(SuppressedAction::ShowHelp) => help_mod, 72 | InputMode::Suppressed(SuppressedAction::Delete) => delete_mod, 73 | InputMode::Command => command_mod 74 | ], 75 | styles: Styles { 76 | selected: Style::default() 77 | .fg(Color::Green) 78 | .add_modifier(Modifier::BOLD), 79 | normal: Style::default().fg(Color::White), 80 | header: Style::default() 81 | .fg(Color::White) 82 | .add_modifier(Modifier::BOLD), 83 | }, 84 | 85 | cols_constraints: default_columns_constraints(), 86 | 87 | display_ids: false, 88 | }) 89 | } 90 | 91 | pub(crate) fn handle_input(&mut self, event: Event) -> Result> { 92 | if let Event::Input(input) = event { 93 | match &self.input_mode { 94 | InputMode::Normal => match input { 95 | Key::Char('q') => { 96 | return Ok(true); 97 | } 98 | Key::Left => { 99 | self.bookmarks_table.unselect(); 100 | } 101 | Key::Down => { 102 | self.bookmarks_table.next(); 103 | } 104 | Key::Up => { 105 | self.bookmarks_table.previous(); 106 | } 107 | Key::Char('\n') => { 108 | self.bookmarks_table.open()?; 109 | } 110 | Key::Char('i') => { 111 | self.toggle_ids_display()?; 112 | } 113 | // Activate first module that can handle the key - if none just skip 114 | _ => { 115 | for m in self.modules.values_mut() { 116 | if let Some(mode) = m.try_activate(input, &mut self.bookmarks_table)? { 117 | self.input_mode = mode; 118 | return Ok(false); 119 | } 120 | } 121 | } 122 | }, 123 | _ => { 124 | if let Some(module) = self.modules.get_mut(&self.input_mode) { 125 | if let Some(new_mode) = 126 | module.handle_input(input, &mut self.bookmarks_table)? 127 | { 128 | self.input_mode = new_mode; 129 | } 130 | } 131 | } 132 | } 133 | } 134 | if let Event::Signal(s) = event { 135 | match s { 136 | Signal::Quit => return Ok(true), 137 | } 138 | } 139 | 140 | Ok(false) 141 | } 142 | 143 | pub(crate) fn draw(&mut self, f: &mut Frame) { 144 | let size = f.size(); 145 | let normal_style = self.styles.normal; 146 | 147 | let chunks = Layout::default() 148 | .direction(Direction::Vertical) 149 | .constraints( 150 | [ 151 | // TODO: consider modules influencing dynamicly the main layout - maybe pass layout to draw? 152 | Constraint::Length(size.height - 3), // URLs display table 153 | Constraint::Length(3), // Search input 154 | ] 155 | .as_ref(), 156 | ) 157 | .split(f.size()); 158 | 159 | let header = Row::new( 160 | self.bookmarks_table 161 | .columns() 162 | .clone() 163 | .into_iter() 164 | .map(|s| Text::raw(s)), 165 | ) 166 | .style(self.styles.header); 167 | let table = self.bookmarks_table.table(); 168 | 169 | let rows = table 170 | .items 171 | .iter() 172 | .map(|i| Row::new(i.row().iter().map(|s| Text::raw(s))).style(normal_style)); 173 | let t = Table::new(rows, &self.cols_constraints) 174 | .header(header) 175 | .block( 176 | Block::default() 177 | .borders(Borders::ALL) 178 | .title("URLs - Press 'h' to show help"), 179 | ) 180 | .highlight_style(self.styles.selected) 181 | .highlight_symbol("> ") 182 | .highlight_spacing(ratatui::widgets::HighlightSpacing::Always); 183 | 184 | f.render_stateful_widget(t, chunks[0], &mut table.state); 185 | 186 | // draw modules 187 | for module in self.modules.values() { 188 | module.draw(self.input_mode.clone(), f) 189 | } 190 | } 191 | 192 | fn toggle_ids_display(&mut self) -> Result<(), Box> { 193 | self.display_ids = !self.display_ids; 194 | 195 | let (cols, constraints) = if self.display_ids { 196 | ( 197 | to_string(vec!["Id", "Name", "URL", "Group", "Tags"]), 198 | columns_with_id_constraints(), 199 | ) 200 | } else { 201 | (default_columns(), default_columns_constraints()) 202 | }; 203 | 204 | self.cols_constraints = constraints; 205 | self.bookmarks_table.set_columns(cols)?; 206 | Ok(()) 207 | } 208 | } 209 | 210 | fn default_columns_constraints() -> Vec { 211 | vec![ 212 | Constraint::Percentage(20), 213 | Constraint::Percentage(40), 214 | Constraint::Percentage(15), 215 | Constraint::Percentage(25), 216 | ] 217 | } 218 | 219 | fn columns_with_id_constraints() -> Vec { 220 | vec![ 221 | Constraint::Percentage(13), 222 | Constraint::Percentage(20), 223 | Constraint::Percentage(35), 224 | Constraint::Percentage(12), 225 | Constraint::Percentage(20), 226 | ] 227 | } 228 | 229 | #[cfg(test)] 230 | pub(crate) mod test { 231 | use crate::interactive::bookmarks_table::BookmarksTable; 232 | use crate::interactive::event::{Event, Events, Signal}; 233 | use crate::interactive::interface::{InputMode, Interface, SuppressedAction}; 234 | use crate::interactive::table::TableItem; 235 | use bookmark_lib::registry::URLRegistry; 236 | use bookmark_lib::types::URLRecord; 237 | use bookmark_lib::Registry; 238 | use rand::distributions::Alphanumeric; 239 | use rand::{thread_rng, Rng}; 240 | use std::fs; 241 | use std::path::{Path, PathBuf}; 242 | use termion::event::Key; 243 | 244 | pub fn to_keys(text: &str) -> Vec { 245 | text.chars().map(Key::Char).collect() 246 | } 247 | 248 | pub fn to_key_events(text: &str) -> Vec> { 249 | text.chars().map(|c| Event::Input(Key::Char(c))).collect() 250 | } 251 | 252 | fn fix_url_records() -> Vec { 253 | vec![ 254 | URLRecord::new("one", "one", "one", vec!["tag", "with space"]), 255 | URLRecord::new("two", "two", "two", Vec::::new()), 256 | URLRecord::new("three", "three", "three", Vec::::new()), 257 | URLRecord::new("four", "four", "four", vec!["tag"]), 258 | URLRecord::new("five", "five", "five", Vec::::new()), 259 | ] 260 | } 261 | 262 | struct Cleaner { 263 | file_path: PathBuf, 264 | } 265 | 266 | // TODO: as general trait? 267 | impl Cleaner { 268 | fn new(file_path: PathBuf) -> Cleaner { 269 | Cleaner { file_path } 270 | } 271 | 272 | fn clean(&self) { 273 | if Path::new(&self.file_path).exists() { 274 | fs::remove_file(&self.file_path).expect("Failed to remove file"); 275 | } 276 | } 277 | } 278 | 279 | impl Drop for Cleaner { 280 | fn drop(&mut self) { 281 | self.clean() 282 | } 283 | } 284 | 285 | fn rand_str() -> String { 286 | let rand_string: String = thread_rng().sample_iter(&Alphanumeric).take(30).collect(); 287 | 288 | rand_string 289 | } 290 | 291 | macro_rules! init { 292 | ($($urls:expr), *) => ( 293 | { 294 | let (registry, file_path) = URLRegistry::with_temp_file(&rand_str()).expect("Failed to initialize registry"); 295 | let cleaner = Cleaner::new(file_path); // makes sure that temp file is deleted even in case of panic 296 | $( 297 | for u in $urls { 298 | registry.add(u).expect("Failed to add url"); 299 | } 300 | )* 301 | 302 | let events = Events::new(); 303 | let bookmarks_table = BookmarksTable::new(events.tx.clone(), Box::new(registry)).expect("Failed to initialize Bookmarks table"); 304 | 305 | let interface = Interface::new(bookmarks_table).expect("Failed to initialize interface"); 306 | 307 | (interface, cleaner) 308 | } 309 | ); 310 | () => ( 311 | init!(vec![]) 312 | ); 313 | } 314 | 315 | #[test] 316 | fn test_handle_input_returns() { 317 | let (mut interface, _) = init!(); 318 | 319 | // Should quit when input 'q' 320 | let event = Event::Input(Key::Char('q')); 321 | let quit = interface 322 | .handle_input(event) 323 | .expect("Failed to handle event"); 324 | assert!(quit); 325 | 326 | // Should pass if key not handled 327 | let event = Event::Input(Key::Char('j')); 328 | let quit = interface 329 | .handle_input(event) 330 | .expect("Failed to handle event"); 331 | assert!(!quit); 332 | 333 | // Should do nothing on enter, when no URL selected 334 | let event = Event::Input(Key::Char('\n')); 335 | let quit = interface 336 | .handle_input(event) 337 | .expect("Failed to handle event"); 338 | assert!(!quit); 339 | 340 | // Should quit on Signal(Quit) 341 | let event = Event::Signal(Signal::Quit); 342 | let quit = interface 343 | .handle_input(event) 344 | .expect("Failed to handle event"); 345 | assert!(quit); 346 | } 347 | 348 | #[test] 349 | fn test_handle_input_input_modes() { 350 | let (mut interface, _) = init!(); 351 | 352 | assert!(InputMode::Normal == interface.input_mode); 353 | 354 | println!("Should switch input modes..."); 355 | let event = Event::Input(Key::Char('/')); 356 | let quit = interface 357 | .handle_input(event) 358 | .expect("Failed to handle event"); 359 | assert!(!quit); 360 | assert!(InputMode::Search == interface.input_mode); 361 | 362 | let event = Event::Input(Key::Esc); 363 | let quit = interface 364 | .handle_input(event) 365 | .expect("Failed to handle event"); 366 | assert!(!quit); 367 | assert!(InputMode::Normal == interface.input_mode); 368 | 369 | let event = Event::Input(Key::Ctrl('f')); 370 | let quit = interface 371 | .handle_input(event) 372 | .expect("Failed to handle event"); 373 | assert!(!quit); 374 | assert!(InputMode::Search == interface.input_mode); 375 | 376 | let event = Event::Input(Key::Esc); 377 | let quit = interface 378 | .handle_input(event) 379 | .expect("Failed to handle event"); 380 | assert!(!quit); 381 | assert!(InputMode::Normal == interface.input_mode); 382 | 383 | let event = Event::Input(Key::Char('h')); 384 | let quit = interface 385 | .handle_input(event) 386 | .expect("Failed to handle event"); 387 | assert!(!quit); 388 | assert!(InputMode::Suppressed(SuppressedAction::ShowHelp) == interface.input_mode); 389 | 390 | let event = Event::Input(Key::Esc); 391 | let quit = interface 392 | .handle_input(event) 393 | .expect("Failed to handle event"); 394 | assert!(!quit); 395 | assert!(InputMode::Normal == interface.input_mode); 396 | 397 | println!("Should go to normal mode..."); 398 | let go_to_normal_events = vec![ 399 | Event::Input(Key::Up), 400 | Event::Input(Key::Down), 401 | Event::Input(Key::Esc), 402 | Event::Input(Key::Char('\n')), 403 | ]; 404 | 405 | for event in go_to_normal_events { 406 | interface.input_mode = InputMode::Search; 407 | let quit = interface 408 | .handle_input(event) 409 | .expect("Failed to handle event"); 410 | assert!(!quit); 411 | assert!(InputMode::Normal == interface.input_mode); 412 | } 413 | 414 | println!("Should go to Search mode..."); 415 | let go_to_search_events = vec![Event::Input(Key::Char('/')), Event::Input(Key::Ctrl('f'))]; 416 | 417 | for event in go_to_search_events { 418 | interface.input_mode = InputMode::Normal; 419 | let quit = interface 420 | .handle_input(event) 421 | .expect("Failed to handle event"); 422 | assert!(!quit); 423 | assert!(InputMode::Search == interface.input_mode); 424 | } 425 | } 426 | 427 | #[test] 428 | fn test_handle_input_switch_input_modes() { 429 | let (mut interface, _) = init!(); 430 | 431 | assert!(InputMode::Normal == interface.input_mode); 432 | 433 | println!("Should switch InputModes..."); 434 | let events = vec![ 435 | Event::Input(Key::Char('h')), 436 | Event::Input(Key::Esc), 437 | Event::Input(Key::Char('h')), 438 | Event::Input(Key::Char('\n')), 439 | Event::Input(Key::Char('h')), 440 | Event::Input(Key::Char('h')), 441 | Event::Input(Key::Char('/')), 442 | Event::Input(Key::Esc), 443 | Event::Input(Key::Ctrl('f')), 444 | Event::Input(Key::Esc), 445 | Event::Input(Key::Char(':')), 446 | Event::Input(Key::Char('a')), 447 | Event::Input(Key::Esc), 448 | ]; 449 | 450 | let expected_modes = vec![ 451 | InputMode::Suppressed(SuppressedAction::ShowHelp), 452 | InputMode::Normal, 453 | InputMode::Suppressed(SuppressedAction::ShowHelp), 454 | InputMode::Normal, 455 | InputMode::Suppressed(SuppressedAction::ShowHelp), 456 | InputMode::Normal, 457 | InputMode::Search, 458 | InputMode::Normal, 459 | InputMode::Search, 460 | InputMode::Normal, 461 | InputMode::Command, 462 | InputMode::Command, 463 | InputMode::Normal, 464 | ]; 465 | 466 | for i in 0..events.len() { 467 | let quit = interface 468 | .handle_input(events[i].clone()) 469 | .expect("Failed to handle event"); 470 | assert!(!quit); 471 | assert!(expected_modes[i] == interface.input_mode); 472 | } 473 | } 474 | 475 | #[test] 476 | fn test_handle_input_search() { 477 | let (mut interface, _cleaner) = init!(fix_url_records()); 478 | 479 | println!("Should filter items in table on input..."); 480 | let event = Event::Input(Key::Char('/')); 481 | let quit = interface 482 | .handle_input(event) 483 | .expect("Failed to handle event"); 484 | assert!(!quit); 485 | 486 | for event in vec![ 487 | Event::Input(Key::Char('t')), 488 | Event::Input(Key::Char('a')), 489 | Event::Input(Key::Char('g')), 490 | ] { 491 | let quit = interface 492 | .handle_input(event) 493 | .expect("Failed to handle event"); 494 | assert!(!quit); 495 | } 496 | assert_eq!(2, interface.bookmarks_table.table().items.len()); // URLs with tag 'tag' 497 | 498 | println!("Should preserve search, when going in and out of Search mode..."); 499 | let event = Event::Input(Key::Esc); 500 | let quit = interface 501 | .handle_input(event) 502 | .expect("Failed to handle event"); 503 | assert!(!quit); 504 | assert!(InputMode::Normal == interface.input_mode); 505 | assert_eq!(2, interface.bookmarks_table.table().items.len()); // URLs with tag 'tag' 506 | 507 | let event = Event::Input(Key::Char('/')); 508 | let quit = interface 509 | .handle_input(event) 510 | .expect("Failed to handle event"); 511 | assert!(!quit); 512 | assert_eq!(2, interface.bookmarks_table.table().items.len()); // URLs with tag 'tag' 513 | 514 | println!("Should filter items in table on backspace..."); 515 | for event in vec![Event::Input(Key::Backspace), Event::Input(Key::Backspace)] { 516 | let quit = interface 517 | .handle_input(event) 518 | .expect("Failed to handle event"); 519 | assert!(!quit); 520 | } 521 | assert_eq!(4, interface.bookmarks_table.table().items.len()); // URLs with letter 't' 522 | } 523 | 524 | struct TestCase { 525 | description: String, 526 | events: Vec>, 527 | selected: Vec>, 528 | } 529 | 530 | #[test] 531 | fn test_handle_input_selections() { 532 | let test_cases = vec![ 533 | TestCase { 534 | description: "multiple ups and downs".to_string(), 535 | events: vec![ 536 | Event::Input(Key::Down), 537 | Event::Input(Key::Down), 538 | Event::Input(Key::Down), 539 | Event::Input(Key::Down), 540 | Event::Input(Key::Down), 541 | Event::Input(Key::Down), 542 | Event::Input(Key::Down), 543 | Event::Input(Key::Up), 544 | Event::Input(Key::Up), 545 | ], 546 | selected: vec![ 547 | Some(0), 548 | Some(1), 549 | Some(2), 550 | Some(3), 551 | Some(4), 552 | Some(0), 553 | Some(1), 554 | Some(0), 555 | Some(4), 556 | ], 557 | }, 558 | TestCase { 559 | description: "unselect".to_string(), 560 | events: vec![ 561 | Event::Input(Key::Down), 562 | Event::Input(Key::Down), 563 | Event::Input(Key::Left), 564 | ], 565 | selected: vec![Some(0), Some(1), None], 566 | }, 567 | TestCase { 568 | description: "unselect with search".to_string(), 569 | events: vec![ 570 | Event::Input(Key::Down), 571 | Event::Input(Key::Down), 572 | Event::Input(Key::Ctrl('f')), 573 | ], 574 | selected: vec![Some(0), Some(1), None], 575 | }, 576 | TestCase { 577 | description: "unselect with search and stop search with up".to_string(), 578 | events: vec![ 579 | Event::Input(Key::Down), 580 | Event::Input(Key::Down), 581 | Event::Input(Key::Ctrl('f')), 582 | Event::Input(Key::Up), 583 | Event::Input(Key::Up), 584 | ], 585 | selected: vec![Some(0), Some(1), None, None, Some(0)], 586 | }, 587 | TestCase { 588 | description: "unselect with search and stop search with down".to_string(), 589 | events: vec![ 590 | Event::Input(Key::Down), 591 | Event::Input(Key::Down), 592 | Event::Input(Key::Char('/')), 593 | Event::Input(Key::Down), 594 | Event::Input(Key::Down), 595 | ], 596 | selected: vec![Some(0), Some(1), None, None, Some(0)], 597 | }, 598 | TestCase { 599 | description: "unselect with search and stop search with esc".to_string(), 600 | events: vec![ 601 | Event::Input(Key::Up), 602 | Event::Input(Key::Up), 603 | Event::Input(Key::Char('/')), 604 | Event::Input(Key::Esc), 605 | Event::Input(Key::Down), 606 | ], 607 | selected: vec![Some(0), Some(4), None, None, Some(0)], 608 | }, 609 | ]; 610 | 611 | for test in &test_cases { 612 | println!("Running test case: {}", test.description); 613 | 614 | let (mut interface, _) = init!(fix_url_records()); 615 | 616 | for i in 0..test.events.len() { 617 | let quit = interface 618 | .handle_input(test.events[i].clone()) 619 | .expect("Failed to handle event"); 620 | assert!(!quit); 621 | assert_eq!( 622 | test.selected[i], 623 | interface.bookmarks_table.table().state.selected() 624 | ) 625 | } 626 | } 627 | } 628 | 629 | #[test] 630 | fn test_toggle_ids() { 631 | let (mut interface, _cleaner) = init!(fix_url_records()); 632 | 633 | println!("Should be hidden at start..."); 634 | let row = interface.bookmarks_table.table().items[0].row(); 635 | assert_eq!(row.len(), 4); 636 | let row = interface.bookmarks_table.table().items[0].row(); 637 | assert_eq!(row.len(), 4); 638 | 639 | println!("Should show ids..."); 640 | let event = Event::Input(Key::Char('i')); 641 | let quit = interface 642 | .handle_input(event) 643 | .expect("Failed to handle event"); 644 | assert!(!quit); 645 | assert_eq!(interface.cols_constraints.len(), 5); 646 | let row = interface.bookmarks_table.table().items[0].row(); 647 | assert_eq!(row.len(), 5); 648 | assert_eq!(row[0].len(), 16); // is 8 byte (16 chars) id 649 | assert_eq!(row[1], "one"); 650 | assert_eq!(row[2], "one"); 651 | assert_eq!(row[3], "one"); 652 | assert_eq!(row[4], "tag, \"with space\""); 653 | 654 | println!("Should hide ids..."); 655 | let event = Event::Input(Key::Char('i')); 656 | let quit = interface 657 | .handle_input(event) 658 | .expect("Failed to handle event"); 659 | assert!(!quit); 660 | assert_eq!(interface.cols_constraints.len(), 4); 661 | let row = interface.bookmarks_table.table().items[0].row(); 662 | assert_eq!(row.len(), 4); 663 | assert_eq!(row[0], "one"); 664 | assert_eq!(row[1], "one"); 665 | assert_eq!(row[2], "one"); 666 | assert_eq!(row[3], "tag, \"with space\""); 667 | } 668 | 669 | struct TestCaseCommands { 670 | commands_chain: Vec<(&'static str, &'static str)>, 671 | } 672 | 673 | #[test] 674 | fn test_commands() { 675 | // Run for full names and aliases 676 | let test_cases = vec![ 677 | TestCaseCommands { 678 | commands_chain: vec![ 679 | (":tag", "abcd"), 680 | (":chgroup", "puorg"), 681 | (":untag", "tag"), 682 | (":chname", "new-name-123"), 683 | (":churl", "https://new-url.com"), 684 | ], 685 | }, 686 | TestCaseCommands { 687 | commands_chain: vec![ 688 | (":tag", "abcd"), 689 | (":chg", "puorg"), 690 | (":untag", "tag"), 691 | (":chn", "new-name-123"), 692 | (":chu", "https://new-url.com"), 693 | ], 694 | }, 695 | TestCaseCommands { 696 | commands_chain: vec![ 697 | (":tag", r#""ab cd""#), 698 | (":chg", r#""wen puorg""#), 699 | (":untag", r#""with space""#), 700 | (":chn", r#""new name 123""#), 701 | (":chu", r#""https://new-url.com""#), 702 | ], 703 | }, 704 | ]; 705 | 706 | for test_case in test_cases { 707 | let (mut interface, _cleaner) = init!(fix_url_records()); 708 | 709 | println!("Select first URL..."); 710 | interface 711 | .handle_input(Event::Input(Key::Down)) 712 | .expect("Failed to handle event"); 713 | 714 | println!("Get URL..."); 715 | let original_url = interface 716 | .bookmarks_table 717 | .get_selected() 718 | .expect("Failed to get URL") 719 | .expect("URL is None"); 720 | assert_eq!(original_url.name, "one"); 721 | assert_eq!(original_url.group, "one"); 722 | assert!(original_url.tags.contains_key("tag")); 723 | assert!(!original_url.tags.contains_key("abcd")); 724 | 725 | println!("Should tag URL..."); 726 | let events = to_key_events(&join_command(test_case.commands_chain[0])); 727 | for e in events { 728 | interface.handle_input(e).expect("Failed to handle event"); 729 | } 730 | 731 | println!("Should change group..."); 732 | let events = to_key_events(&join_command(test_case.commands_chain[1])); 733 | for e in events { 734 | interface.handle_input(e).expect("Failed to handle event"); 735 | } 736 | 737 | println!("Should remove tag..."); 738 | let events = to_key_events(&join_command(test_case.commands_chain[2])); 739 | for e in events { 740 | interface.handle_input(e).expect("Failed to handle event"); 741 | } 742 | 743 | println!("Should change name..."); 744 | let events = to_key_events(&join_command(test_case.commands_chain[3])); 745 | for e in events { 746 | interface.handle_input(e).expect("Failed to handle event"); 747 | } 748 | 749 | println!("Should change URL..."); 750 | let events = to_key_events(&join_command(test_case.commands_chain[4])); 751 | for e in events { 752 | interface.handle_input(e).expect("Failed to handle event"); 753 | } 754 | 755 | println!("Verify URL record..."); 756 | let modified_url = interface 757 | .bookmarks_table 758 | .get_selected() 759 | .expect("Failed to get URL") 760 | .expect("URL is None"); 761 | assert_eq!(modified_url.name, unquote(test_case.commands_chain[3].1)); 762 | assert_eq!(modified_url.url, unquote(test_case.commands_chain[4].1)); 763 | assert_eq!(modified_url.group, unquote(test_case.commands_chain[1].1)); 764 | assert!(modified_url 765 | .tags 766 | .contains_key(unquote(test_case.commands_chain[0].1))); 767 | assert!(!modified_url 768 | .tags 769 | .contains_key(unquote(test_case.commands_chain[2].1))); 770 | } 771 | } 772 | 773 | fn join_command(cmd: (&str, &str)) -> String { 774 | format!("{} {}\n", cmd.0, cmd.1) 775 | } 776 | 777 | fn unquote(str: &str) -> &str { 778 | str.trim_start_matches('"').trim_end_matches('"') 779 | } 780 | } 781 | -------------------------------------------------------------------------------- /src/bin/interactive/mod.rs: -------------------------------------------------------------------------------- 1 | mod event; 2 | 3 | mod table; 4 | 5 | mod url_table_item; 6 | 7 | #[macro_use] 8 | mod helpers; 9 | 10 | mod interface; 11 | 12 | mod widgets; 13 | 14 | mod modules; 15 | 16 | mod bookmarks_table; 17 | 18 | pub mod interactive_mode; 19 | 20 | pub mod subcommand; 21 | -------------------------------------------------------------------------------- /src/bin/interactive/modules/command.rs: -------------------------------------------------------------------------------- 1 | use crate::interactive::bookmarks_table::BookmarksTable; 2 | use crate::interactive::interface::InputMode; 3 | use crate::interactive::modules::{Draw, HandleInput, Module}; 4 | use ratatui::layout::Rect; 5 | use ratatui::style::Style; 6 | use ratatui::text::Text; 7 | use ratatui::widgets::{Block, Borders, Clear, Paragraph}; 8 | use ratatui::Frame; 9 | use regex::Regex; 10 | use std::error::Error; 11 | use termion::event::Key; 12 | 13 | const DEFAULT_INFO_MESSAGE: &str = 14 | "Press 'Enter' to execute command on selected Bookmark. Press 'Esc' to discard."; 15 | 16 | pub(crate) struct Command { 17 | info_display: String, 18 | command_input: String, 19 | command_display: String, 20 | args_regex: Regex, 21 | } 22 | 23 | impl Module for Command {} 24 | 25 | impl HandleInput for Command { 26 | fn try_activate( 27 | &mut self, 28 | input: Key, 29 | _table: &mut BookmarksTable, 30 | ) -> Result, Box> { 31 | if input != Key::Char(':') { 32 | return Ok(None); 33 | } 34 | 35 | Ok(Some(InputMode::Command)) 36 | } 37 | 38 | fn handle_input( 39 | &mut self, 40 | input: Key, 41 | table: &mut BookmarksTable, 42 | ) -> Result, Box> { 43 | match input { 44 | Key::Esc => { 45 | self.reset_input(); 46 | return Ok(Some(InputMode::Normal)); 47 | } 48 | Key::Char('\n') => { 49 | if self.command_input == "" { 50 | return Ok(None); 51 | } 52 | 53 | let action_index = self 54 | .command_input 55 | .find(' ') 56 | .unwrap_or_else(|| self.command_input.len()); 57 | 58 | let action = &self.command_input.as_str()[0..action_index]; 59 | 60 | let args: Vec<&str> = if action_index < self.command_input.len() { 61 | self.parse_args(&(self.command_input.as_str())[action_index + 1..])? 62 | .iter() 63 | .map(|s| s.to_owned()) 64 | .filter(|s| *s != "") 65 | .collect() 66 | } else { 67 | vec![] 68 | }; 69 | 70 | return match table.exec(action, args) { 71 | // TODO: here I want error, command error and msg 72 | Ok(_) => { 73 | self.reset_input(); 74 | Ok(Some(InputMode::Normal)) 75 | } 76 | Err(err) => { 77 | self.info_display = err.to_string(); 78 | Ok(None) 79 | } 80 | }; 81 | } 82 | Key::Char(c) => { 83 | self.input_push(c); 84 | } 85 | Key::Backspace => self.input_pop(), 86 | _ => self.update_display(), 87 | } 88 | 89 | Ok(None) 90 | } 91 | } 92 | 93 | impl Draw for Command { 94 | fn draw(&self, mode: InputMode, f: &mut Frame) { 95 | match mode { 96 | InputMode::Command => { 97 | self.render_command_input(f); 98 | // Make the cursor visible and ask ratatui-rs to put it at the specified coordinates after rendering 99 | f.set_cursor( 100 | // Put cursor past the end of the input text 101 | self.command_display.len() as u16 + 1, // TODO: consider using crate UnicodeWidth 102 | // Move two line up from the bottom - search input 103 | f.size().height - 5, 104 | ) 105 | } 106 | _ => { 107 | // if search phrase is not empty - keep displaying search box 108 | if self.command_input != "" { 109 | self.render_command_input(f); 110 | } 111 | } 112 | } 113 | } 114 | } 115 | 116 | impl Command { 117 | pub fn new() -> Result> { 118 | Ok(Command { 119 | info_display: DEFAULT_INFO_MESSAGE.to_string(), 120 | command_input: "".to_string(), 121 | command_display: ":".to_string(), 122 | args_regex: Regex::new(r#"("[^"]*")|(\S+)"#)?, // Match either strings in quotes (with spaces and stuff) or single words 123 | }) 124 | } 125 | 126 | fn input_push(&mut self, ch: char) { 127 | self.command_input.push(ch); 128 | self.update_display(); 129 | } 130 | fn input_pop(&mut self) { 131 | self.command_input.pop(); 132 | self.update_display(); 133 | } 134 | 135 | fn update_display(&mut self) { 136 | self.command_display = format!(":{}", self.command_input) 137 | } 138 | 139 | fn reset_input(&mut self) { 140 | self.info_display = DEFAULT_INFO_MESSAGE.to_string(); 141 | self.command_input = "".to_string(); 142 | self.update_display(); 143 | } 144 | 145 | fn parse_args<'a>(&self, args: &'a str) -> Result, Box> { 146 | Ok(self 147 | .args_regex 148 | .find_iter(args) 149 | .map(|m| { 150 | let str = m.as_str(); 151 | // Strip quotes 152 | if str.starts_with('"') && str.ends_with('"') { 153 | if str.len() == 1 { 154 | // In case pattern is """ 155 | "" 156 | } else { 157 | &str[1..str.len() - 1] 158 | } 159 | } else { 160 | str 161 | } 162 | }) 163 | .collect()) 164 | } 165 | 166 | pub fn render_command_input(&self, f: &mut Frame) { 167 | let info_widget = Paragraph::new(Text::raw(&self.info_display)) 168 | .style(Style::default()) 169 | .block(Block::default().borders(Borders::TOP)); 170 | 171 | let input_widget = Paragraph::new(Text::raw(&self.command_display)) 172 | .style(Style::default()) 173 | .block(Block::default().borders(Borders::BOTTOM)); 174 | 175 | let r = f.size(); 176 | let info_block = Rect::new(1, r.height - 7, r.width - 2, 2); 177 | let input_block = Rect::new(1, r.height - 5, r.width - 2, 2); 178 | 179 | f.render_widget(Clear, info_block); 180 | f.render_widget(info_widget, info_block); // TODO: render stateful widget? 181 | f.render_widget(Clear, input_block); 182 | f.render_widget(input_widget, input_block); // TODO: render stateful widget? 183 | } 184 | } 185 | 186 | #[cfg(test)] 187 | mod test { 188 | use crate::interactive::bookmarks_table::BookmarksTable; 189 | use crate::interactive::event::Events; 190 | use crate::interactive::interface::test::to_keys; 191 | use crate::interactive::interface::InputMode; 192 | use crate::interactive::modules::command::{Command, DEFAULT_INFO_MESSAGE}; 193 | use crate::interactive::modules::HandleInput; 194 | use bookmark_lib::registry::URLRegistry; 195 | use bookmark_lib::Registry; 196 | use termion::event::Key; 197 | 198 | #[test] 199 | fn test_exec_command() { 200 | let mut command_module = Command::new().expect("Failed to create command module"); 201 | let (registry, _) = URLRegistry::with_temp_file("command_test1.json") 202 | .expect("Failed to initialize Registry"); 203 | registry 204 | .create("abcd", "url", None, vec![]) 205 | .expect("Failed to create Bookmark"); 206 | let events = Events::new(); 207 | 208 | let mut bookmarks_table = BookmarksTable::new(events.tx.clone(), Box::new(registry)) 209 | .expect("Failed to initialized Bookmarks table"); 210 | 211 | println!("Should input command phrase..."); 212 | let key_events = to_keys("tag test"); 213 | 214 | for key in key_events { 215 | let mode = command_module 216 | .handle_input(key, &mut bookmarks_table) 217 | .expect("Failed to handle event"); 218 | assert!(mode == None); 219 | } 220 | 221 | println!("Should execute 'tag' command..."); 222 | bookmarks_table.table().state.select(Some(0)); 223 | let mode = command_module 224 | .handle_input(Key::Char('\n'), &mut bookmarks_table) 225 | .expect("Failed to handle event"); 226 | assert!(mode == Some(InputMode::Normal)); 227 | assert_eq!(command_module.info_display, DEFAULT_INFO_MESSAGE); 228 | assert_eq!(command_module.command_input, ""); 229 | assert_eq!(command_module.command_display, ":"); 230 | } 231 | 232 | #[test] 233 | fn test_command_with_no_args() { 234 | let mut command_module = Command::new().expect("Failed to create command module"); 235 | let (registry, _) = URLRegistry::with_temp_file("command_test2.json") 236 | .expect("Failed to initialize Registry"); 237 | registry 238 | .create("xyz", "url_xyz", None, vec![]) 239 | .expect("Failed to create Bookmark"); 240 | registry 241 | .create("abcd", "url_abcd", None, vec![]) 242 | .expect("Failed to create Bookmark"); 243 | let events = Events::new(); 244 | 245 | let mut bookmarks_table = BookmarksTable::new(events.tx.clone(), Box::new(registry)) 246 | .expect("Failed to initialized Bookmarks table"); 247 | 248 | println!("Should input command phrase..."); 249 | let key_events = to_keys("sort"); 250 | 251 | for key in key_events { 252 | let mode = command_module 253 | .handle_input(key, &mut bookmarks_table) 254 | .expect("Failed to handle event"); 255 | assert!(mode == None); 256 | } 257 | 258 | println!("Should execute 'sort' command..."); 259 | let mode = command_module 260 | .handle_input(Key::Char('\n'), &mut bookmarks_table) 261 | .expect("Failed to handle event"); 262 | assert!(mode == Some(InputMode::Normal)); 263 | assert_eq!(command_module.info_display, DEFAULT_INFO_MESSAGE); 264 | assert_eq!(command_module.command_input, ""); 265 | assert_eq!(command_module.command_display, ":"); 266 | assert_eq!(bookmarks_table.table().items[0].url(), "url_abcd"); 267 | } 268 | 269 | #[test] 270 | fn test_exec_display_error_message_when_cmd_failed() { 271 | let mut command_module = Command::new().expect("Failed to create command module"); 272 | let (registry, _) = URLRegistry::with_temp_file("command_test3.json") 273 | .expect("Failed to initialize Registry"); 274 | let events = Events::new(); 275 | 276 | let mut bookmarks_table = BookmarksTable::new(events.tx.clone(), Box::new(registry)) 277 | .expect("Failed to initialized Bookmarks table"); 278 | 279 | println!("Should input command phrase..."); 280 | let key_events = to_keys("tag test"); 281 | 282 | for key in key_events { 283 | let mode = command_module 284 | .handle_input(key, &mut bookmarks_table) 285 | .expect("Failed to handle event"); 286 | assert!(mode == None); 287 | } 288 | 289 | println!("Should fail to execute 'tag' command when no item selected..."); 290 | let mode = command_module 291 | .handle_input(Key::Char('\n'), &mut bookmarks_table) 292 | .expect("Failed to handle event"); 293 | assert!(mode == None); 294 | assert_eq!(command_module.info_display, "error: item not selected"); 295 | assert_eq!(command_module.command_input, "tag test"); 296 | assert_eq!(command_module.command_display, ":tag test"); 297 | } 298 | 299 | #[test] 300 | fn test_do_nothing_when_input_empty() { 301 | let mut command_module = Command::new().expect("Failed to create command module"); 302 | let (registry, _) = URLRegistry::with_temp_file("command_test4.json") 303 | .expect("Failed to initialize Registry"); 304 | let events = Events::new(); 305 | 306 | let mut bookmarks_table = BookmarksTable::new(events.tx.clone(), Box::new(registry)) 307 | .expect("Failed to initialized Bookmarks table"); 308 | 309 | println!("Should do nothing when input is empty..."); 310 | let mode = command_module 311 | .handle_input(Key::Char('\n'), &mut bookmarks_table) 312 | .expect("Failed to handle event"); 313 | assert!(mode == None); 314 | assert_eq!(command_module.info_display, DEFAULT_INFO_MESSAGE); 315 | assert_eq!(command_module.command_input, ""); 316 | assert_eq!(command_module.command_display, ":"); 317 | } 318 | 319 | #[test] 320 | fn test_handle_input_write_command() { 321 | let mut command_module = Command::new().expect("Failed to create command module"); 322 | let (registry, _) = URLRegistry::with_temp_file("command_test5.json") 323 | .expect("Failed to initialize Registry"); 324 | let events = Events::new(); 325 | 326 | let mut bookmarks_table = BookmarksTable::new(events.tx.clone(), Box::new(registry)) 327 | .expect("Failed to initialized Bookmarks table"); 328 | 329 | println!("Should input command phrase..."); 330 | let key_events = to_keys("tag test"); 331 | 332 | for key in key_events { 333 | let mode = command_module 334 | .handle_input(key, &mut bookmarks_table) 335 | .expect("Failed to handle event"); 336 | assert!(mode == None); 337 | } 338 | assert_eq!("tag test".to_string(), command_module.command_input); 339 | 340 | let key_events = vec![ 341 | Key::Backspace, 342 | Key::Backspace, 343 | Key::Char('m'), 344 | Key::Char('p'), 345 | ]; 346 | 347 | for key in key_events { 348 | let mode = command_module 349 | .handle_input(key, &mut bookmarks_table) 350 | .expect("Failed to handle event"); 351 | assert!(mode == None); 352 | } 353 | assert_eq!("tag temp".to_string(), command_module.command_input); 354 | } 355 | 356 | struct ParseArgsTest { 357 | str: &'static str, 358 | expected_args: Vec<&'static str>, 359 | } 360 | 361 | #[test] 362 | fn test_parse_args() { 363 | let command_module = Command::new().expect("Failed to create command module"); 364 | 365 | for test in vec![ 366 | ParseArgsTest { 367 | str: r#""tests 1" test2 test3"#, 368 | expected_args: vec!["tests 1", "test2", "test3"], 369 | }, 370 | ParseArgsTest { 371 | str: r#""tests 1 2 3 4" test2"#, 372 | expected_args: vec!["tests 1 2 3 4", "test2"], 373 | }, 374 | ParseArgsTest { 375 | str: r#""test" test2"#, 376 | expected_args: vec!["test", "test2"], 377 | }, 378 | ParseArgsTest { 379 | str: "test", 380 | expected_args: vec!["test"], 381 | }, 382 | ParseArgsTest { 383 | str: "", 384 | expected_args: vec![], 385 | }, 386 | ParseArgsTest { 387 | str: r#"""#, 388 | expected_args: vec![], 389 | }, 390 | ParseArgsTest { 391 | str: r#""abcd def"ghj"#, 392 | expected_args: vec!["abcd def", "ghj"], 393 | }, 394 | ParseArgsTest { 395 | str: r#""abcd"def"ghj ""#, 396 | expected_args: vec!["abcd", "def\"ghj"], 397 | }, 398 | ParseArgsTest { 399 | str: r#""https://some.url-with-chars.io and space?""#, 400 | expected_args: vec!["https://some.url-with-chars.io and space?"], 401 | }, 402 | ParseArgsTest { 403 | str: r#""https://some.url-with-chars.io/no/space?but=args&more" https://some.url-with-chars.io/no/space?but=args&more"#, 404 | expected_args: vec![ 405 | "https://some.url-with-chars.io/no/space?but=args&more", 406 | "https://some.url-with-chars.io/no/space?but=args&more", 407 | ], 408 | }, 409 | ] { 410 | let out: Vec<&str> = command_module 411 | .parse_args(test.str) 412 | .expect("Error parsing args") 413 | .iter() 414 | .map(|s| s.to_owned()) 415 | .filter(|s| *s != "") 416 | .collect(); 417 | 418 | println!("{:?}", out); 419 | assert_eq!(out, test.expected_args); 420 | } 421 | } 422 | } 423 | -------------------------------------------------------------------------------- /src/bin/interactive/modules/delete.rs: -------------------------------------------------------------------------------- 1 | use crate::interactive::bookmarks_table::BookmarksTable; 2 | use crate::interactive::interface::{InputMode, SuppressedAction}; 3 | use crate::interactive::modules::{Draw, HandleInput, Module}; 4 | use crate::interactive::widgets::rect::centered_fixed_rect; 5 | use bookmark_lib::types::URLRecord; 6 | use ratatui::layout::Alignment; 7 | use ratatui::style::{Color, Modifier, Style}; 8 | use ratatui::text::{Span, Text}; 9 | use ratatui::widgets::{Block, Borders, Clear, Paragraph}; 10 | use ratatui::Frame; 11 | use std::error::Error; 12 | use termion::event::Key; 13 | 14 | // TODO: consider some generic mechanism for actions that require confirmation 15 | 16 | pub(crate) struct Delete { 17 | record: Option, 18 | } 19 | 20 | impl Module for Delete {} 21 | 22 | impl HandleInput for Delete { 23 | fn try_activate( 24 | &mut self, 25 | input: Key, 26 | table: &mut BookmarksTable, 27 | ) -> Result, Box> { 28 | if input != Key::Char('d') { 29 | return Ok(None); 30 | } 31 | 32 | self.record = table.get_selected()?; 33 | if self.record.is_none() { 34 | return Ok(Some(InputMode::Normal)); 35 | } 36 | 37 | Ok(Some(InputMode::Suppressed(SuppressedAction::Delete))) 38 | } 39 | 40 | fn handle_input( 41 | &mut self, 42 | input: Key, 43 | table: &mut BookmarksTable, 44 | ) -> Result, Box> { 45 | match input { 46 | Key::Char('\n') => { 47 | table.delete()?; 48 | return Ok(Some(InputMode::Normal)); 49 | } 50 | Key::Char('q') | Key::Esc => { 51 | return Ok(Some(InputMode::Normal)); 52 | } 53 | _ => {} 54 | } 55 | 56 | Ok(None) 57 | } 58 | } 59 | 60 | impl Draw for Delete { 61 | fn draw(&self, mode: InputMode, f: &mut Frame) { 62 | if let InputMode::Suppressed(SuppressedAction::Delete) = mode { 63 | self.confirm_delete_popup(f) 64 | } 65 | } 66 | } 67 | 68 | impl Delete { 69 | pub fn new() -> Delete { 70 | Delete { record: None } 71 | } 72 | 73 | fn confirm_delete_popup(&self, f: &mut Frame) { 74 | let area = centered_fixed_rect(50, 10, f.size()); 75 | 76 | let record = self 77 | .record 78 | .clone() 79 | .expect("Error displaying delete confirmation"); 80 | 81 | let mut text = Text::raw(""); 82 | text.push_line(format!( 83 | "Delete '{}' from '{}' group?", 84 | record.name, record.group 85 | )); 86 | text.push_line(""); 87 | text.push_line("Yes (Enter) --- No (ESC)"); // TODO: consider y and n as confirmation 88 | 89 | // TODO: remove duplicated code 90 | let block = Block::default() 91 | .borders(Borders::ALL) 92 | .style(Style::default().bg(Color::Black).fg(Color::LightBlue)) 93 | .title(Span::styled( 94 | "Confirm deletion".to_string(), 95 | Style::default().add_modifier(Modifier::BOLD), 96 | )); 97 | 98 | let paragraph = Paragraph::new(text) 99 | .style(Style::default().bg(Color::Black).fg(Color::White)) 100 | .block(block) 101 | .alignment(Alignment::Center); 102 | 103 | f.render_widget(Clear, area); 104 | f.render_widget(paragraph, area); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/bin/interactive/modules/help.rs: -------------------------------------------------------------------------------- 1 | use crate::interactive::bookmarks_table::BookmarksTable; 2 | use crate::interactive::interface::{InputMode, SuppressedAction}; 3 | use crate::interactive::modules::{Draw, HandleInput, Module}; 4 | use crate::interactive::widgets::rect::centered_fixed_rect; 5 | use ratatui::layout::Alignment; 6 | use ratatui::style::{Color, Modifier, Style}; 7 | use ratatui::text::{Line, Span}; 8 | use ratatui::widgets::{Block, Borders, Clear, Paragraph}; 9 | use ratatui::Frame; 10 | use std::error::Error; 11 | use termion::event::Key; 12 | 13 | pub(crate) struct HelpPanel {} 14 | 15 | impl Module for HelpPanel {} 16 | 17 | impl HandleInput for HelpPanel { 18 | fn try_activate( 19 | &mut self, 20 | input: Key, 21 | _table: &mut BookmarksTable, 22 | ) -> Result, Box> { 23 | if input != Key::Char('h') { 24 | return Ok(None); 25 | } 26 | 27 | Ok(Some(InputMode::Suppressed(SuppressedAction::ShowHelp))) 28 | } 29 | 30 | fn handle_input( 31 | &mut self, 32 | input: Key, 33 | _table: &mut BookmarksTable, 34 | ) -> Result, Box> { 35 | match input { 36 | Key::Esc | Key::Char('\n') | Key::Char('h') => { 37 | return Ok(Some(InputMode::Normal)); 38 | } 39 | Key::Char('q') => { 40 | return Ok(Some(InputMode::Normal)); 41 | } 42 | _ => {} 43 | } 44 | 45 | Ok(None) 46 | } 47 | } 48 | 49 | impl Draw for HelpPanel { 50 | fn draw(&self, mode: InputMode, f: &mut Frame) { 51 | if mode == InputMode::Suppressed(SuppressedAction::ShowHelp) { 52 | self.show_help_popup(f); 53 | } 54 | } 55 | } 56 | 57 | impl HelpPanel { 58 | pub fn new() -> HelpPanel { 59 | HelpPanel {} 60 | } 61 | 62 | // TODO: consider using consts from cmd - or embedding docs? 63 | fn show_help_popup(&self, f: &mut Frame) { 64 | let text = vec![ 65 | "Action Description", 66 | "'ENTER' | open bookmarked URL", 67 | "'/' or 'CTRL + F' | search for URLs", 68 | "'d' | delete URL", 69 | "'i' | show/hide ids", 70 | "'q' | exit interactive mode", 71 | "':' | go to command mode", 72 | "", 73 | "", 74 | "Command Alias Description", 75 | "':tag ' | | add tag to selected bookmark", 76 | "':untag ' | | remove tag from selected bookmark", 77 | "':chgroup ' | chg | change group to for selected bookmark", 78 | "':chname ' | chn | change name to for selected bookmark", 79 | "':churl ' | chu | change url to for selected bookmark", 80 | "':sort [SORT_BY]' | | sort bookmarks by one of: [name, url, group]", 81 | "':q' | quit | exit interactive mode", 82 | "", 83 | ]; 84 | let max_width = text.iter().map(|t| t.len()).max().unwrap_or_default() as u16; 85 | let lines: Vec = text.iter().map(|t| Line::from(t.to_owned())).collect(); 86 | 87 | let block = Block::default() 88 | .borders(Borders::ALL) 89 | .style(Style::default().bg(Color::Black).fg(Color::LightBlue)) 90 | .title(Span::styled( 91 | "Help - press ESC to close".to_string(), 92 | Style::default().add_modifier(Modifier::BOLD), 93 | )); 94 | 95 | let area = centered_fixed_rect(max_width + 4, text.len() as u16 + 2, f.size()); 96 | let paragraph = Paragraph::new(lines) 97 | .style(Style::default().bg(Color::Black).fg(Color::White)) 98 | .block(block) 99 | .alignment(Alignment::Left); 100 | 101 | f.render_widget(Clear, area); 102 | f.render_widget(paragraph, area); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/bin/interactive/modules/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::interactive::bookmarks_table::BookmarksTable; 2 | use crate::interactive::interface::InputMode; 3 | use ratatui::Frame; 4 | use termion::event::Key; 5 | 6 | pub mod command; 7 | pub mod delete; 8 | pub mod help; 9 | pub mod search; 10 | 11 | pub trait Module: HandleInput + Draw {} 12 | 13 | pub trait HandleInput { 14 | /// Activates Module 15 | fn try_activate( 16 | &mut self, 17 | input: Key, 18 | table: &mut BookmarksTable, 19 | ) -> Result, Box>; 20 | /// Handles input key when Module already active 21 | fn handle_input( 22 | &mut self, 23 | input: Key, 24 | table: &mut BookmarksTable, 25 | ) -> Result, Box>; 26 | } 27 | 28 | pub trait Draw { 29 | fn draw(&self, mode: InputMode, f: &mut Frame); 30 | } 31 | -------------------------------------------------------------------------------- /src/bin/interactive/modules/search.rs: -------------------------------------------------------------------------------- 1 | use crate::interactive::bookmarks_table::BookmarksTable; 2 | use crate::interactive::interface::InputMode; 3 | use crate::interactive::modules::{Draw, HandleInput, Module}; 4 | use ratatui::layout::Rect; 5 | use ratatui::style::Style; 6 | use ratatui::text::Text; 7 | use ratatui::widgets::{Block, Borders, Clear, Paragraph}; 8 | use ratatui::Frame; 9 | use std::error::Error; 10 | use termion::event::Key; 11 | 12 | pub(crate) struct Search { 13 | search_phrase: String, 14 | } 15 | 16 | impl Module for Search {} 17 | 18 | impl HandleInput for Search { 19 | fn try_activate( 20 | &mut self, 21 | input: Key, 22 | table: &mut BookmarksTable, 23 | ) -> Result, Box> { 24 | if input != Key::Char('/') && input != Key::Ctrl('f') { 25 | return Ok(None); 26 | } 27 | 28 | table.unselect(); 29 | Ok(Some(InputMode::Search)) 30 | } 31 | 32 | fn handle_input( 33 | &mut self, 34 | input: Key, 35 | table: &mut BookmarksTable, 36 | ) -> Result, Box> { 37 | match input { 38 | Key::Esc | Key::Up | Key::Down | Key::Char('\n') => { 39 | table.unselect(); 40 | return Ok(Some(InputMode::Normal)); 41 | } 42 | Key::Char(c) => { 43 | self.search_phrase.push(c); 44 | } 45 | Key::Backspace => { 46 | self.search_phrase.pop(); 47 | } 48 | _ => {} 49 | } 50 | 51 | table.search(&self.search_phrase)?; 52 | Ok(None) 53 | } 54 | } 55 | 56 | impl Draw for Search { 57 | fn draw(&self, mode: InputMode, f: &mut Frame) { 58 | match mode { 59 | InputMode::Search => { 60 | self.render_search_input(f); 61 | // Make the cursor visible and ask ratatui-rs to put it at the specified coordinates after rendering 62 | f.set_cursor( 63 | // Put cursor past the end of the input text 64 | self.search_phrase.len() as u16 + 1, // TODO: consider using crate UnicodeWidth 65 | // Move two line up from the bottom - search input 66 | f.size().height - 2, 67 | ) 68 | } 69 | _ => { 70 | // if search phrase is not empty - keep displaying search box 71 | if self.search_phrase != "" { 72 | self.render_search_input(f); 73 | } 74 | } 75 | } 76 | } 77 | } 78 | 79 | impl Search { 80 | pub fn new() -> Search { 81 | Search { 82 | search_phrase: "".to_string(), 83 | } 84 | } 85 | 86 | pub fn render_search_input(&self, f: &mut Frame) { 87 | let input_widget = Paragraph::new(Text::raw(&self.search_phrase)) 88 | .style(Style::default()) 89 | .block( 90 | Block::default() 91 | .borders(Borders::ALL) 92 | .title("Press '/' or 'CTRL + f' to search for URLs"), 93 | ); 94 | let r = f.size(); 95 | let search_block = Rect::new(0, r.height - 3, r.width, 3); 96 | 97 | f.render_widget(Clear, search_block); 98 | f.render_widget(input_widget, search_block); // TODO: render stateful widget? 99 | } 100 | } 101 | 102 | #[cfg(test)] 103 | mod test { 104 | use crate::interactive::bookmarks_table::BookmarksTable; 105 | use crate::interactive::event::Events; 106 | use crate::interactive::interface::test::to_keys; 107 | use crate::interactive::modules::search::Search; 108 | use crate::interactive::modules::HandleInput; 109 | use bookmark_lib::registry::URLRegistry; 110 | use termion::event::Key; 111 | 112 | #[test] 113 | fn test_handle_input_search_phrase() { 114 | let mut search_module = Search::new(); 115 | let (dummy_registry, _) = URLRegistry::with_temp_file("search_test1.json") 116 | .expect("Failed to initialize Registry"); 117 | let events = Events::new(); 118 | 119 | let mut bookmarks_table = BookmarksTable::new(events.tx.clone(), Box::new(dummy_registry)) 120 | .expect("Failed to initialized Bookmarks table"); 121 | 122 | println!("Should input search phrase..."); 123 | let key_events = to_keys("test 1"); 124 | 125 | for key in key_events { 126 | let mode = search_module 127 | .handle_input(key, &mut bookmarks_table) 128 | .expect("Failed to handle event"); 129 | assert!(mode == None); 130 | } 131 | assert_eq!("test 1".to_string(), search_module.search_phrase); 132 | 133 | let key_events = vec![ 134 | Key::Backspace, 135 | Key::Backspace, 136 | Key::Char('-'), 137 | Key::Char('2'), 138 | ]; 139 | 140 | for key in key_events { 141 | let mode = search_module 142 | .handle_input(key, &mut bookmarks_table) 143 | .expect("Failed to handle event"); 144 | assert!(mode == None); 145 | } 146 | assert_eq!("test-2".to_string(), search_module.search_phrase); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/bin/interactive/subcommand/add.rs: -------------------------------------------------------------------------------- 1 | use crate::interactive::subcommand::{ask_for_string, require_string}; 2 | 3 | #[derive(Debug)] 4 | pub struct AddData { 5 | pub name: String, 6 | pub url: String, 7 | pub group: String, 8 | pub tags: Vec, 9 | } 10 | 11 | impl AddData { 12 | pub fn new(name: &str, url: &str, group: &str, tags: &[String]) -> AddData { 13 | AddData { 14 | name: name.to_string(), 15 | url: url.to_string(), 16 | group: group.to_string(), 17 | tags: tags 18 | .to_vec() 19 | .into_iter() 20 | .filter(|t| !t.is_empty()) 21 | .collect(), 22 | } 23 | } 24 | } 25 | 26 | pub fn interactive_add(add_data: AddData) -> Result> { 27 | let name = if add_data.name.is_empty() { 28 | require_string("Bookmark name", "Bookmark name is required!")? 29 | } else { 30 | ask_for_string("Bookmark name", &add_data.name)? 31 | }; 32 | 33 | let url = if add_data.url.is_empty() { 34 | require_string("Bookmark URL", "Bookmark URL is required!")? 35 | } else { 36 | ask_for_string("Bookmark URL", &add_data.url)? 37 | }; 38 | 39 | let group = ask_for_string("Bookmark group", &add_data.group)?; 40 | let tags_raw = ask_for_string("Tags", &add_data.tags.join(", "))?; 41 | 42 | // TODO: handle whitespaces better here 43 | let tags: Vec = tags_raw 44 | .split(", ") 45 | .into_iter() 46 | .map(|f| f.to_string()) 47 | .collect(); 48 | 49 | // TODO: after adding validation, add it here too 50 | 51 | Ok(AddData::new(&name, &url, &group, &tags)) 52 | } 53 | -------------------------------------------------------------------------------- /src/bin/interactive/subcommand/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod add; 2 | 3 | use std::io; 4 | use std::io::Write; 5 | 6 | pub(crate) fn require_string( 7 | req: &str, 8 | require_msg: &str, 9 | ) -> Result> { 10 | let mut str = ask_for_string(req, "")?; 11 | while str.is_empty() { 12 | println!("{}", require_msg); 13 | str = ask_for_string(req, "")?; 14 | } 15 | 16 | Ok(str) 17 | } 18 | 19 | pub(crate) fn ask_for_string( 20 | req: &str, 21 | default: &str, 22 | ) -> Result> { 23 | let mut input_req = req.to_string(); 24 | if !default.is_empty() { 25 | input_req.push_str(&format!(" ({})", default)); 26 | } 27 | 28 | print!("{}: ", input_req); 29 | let _ = io::stdout().flush()?; 30 | 31 | let mut buffer = String::new(); 32 | io::stdin().read_line(&mut buffer)?; 33 | if let Some('\n') = buffer.chars().next_back() { 34 | buffer.pop(); 35 | } 36 | if let Some('\r') = buffer.chars().next_back() { 37 | buffer.pop(); 38 | } 39 | 40 | if buffer.is_empty() { 41 | Ok(default.to_string()) 42 | } else { 43 | Ok(buffer) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/bin/interactive/table.rs: -------------------------------------------------------------------------------- 1 | use ratatui::widgets::TableState; 2 | 3 | pub trait TableItem { 4 | fn row(&self) -> &Vec; 5 | fn id(&self) -> String; 6 | } 7 | 8 | pub struct StatefulTable { 9 | pub items: Vec, 10 | pub state: TableState, 11 | } 12 | 13 | impl StatefulTable { 14 | pub fn with_items(items: Vec) -> StatefulTable { 15 | StatefulTable { 16 | state: TableState::default(), 17 | items, 18 | } 19 | } 20 | 21 | pub fn override_items(&mut self, items: Vec) { 22 | self.items = items; 23 | } 24 | 25 | pub fn next(&mut self) { 26 | let i = match self.state.selected() { 27 | Some(i) => { 28 | if i >= self.items.len() - 1 { 29 | 0 30 | } else { 31 | i + 1 32 | } 33 | } 34 | None => 0, 35 | }; 36 | self.state.select(Some(i)); 37 | } 38 | 39 | pub fn previous(&mut self) { 40 | let i = match self.state.selected() { 41 | Some(i) => { 42 | if i == 0 { 43 | self.items.len() - 1 44 | } else { 45 | i - 1 46 | } 47 | } 48 | None => 0, 49 | }; 50 | self.state.select(Some(i)); 51 | } 52 | 53 | pub fn unselect(&mut self) { 54 | self.state.select(None); 55 | } 56 | } 57 | 58 | #[cfg(test)] 59 | mod test { 60 | use crate::interactive::table::StatefulTable; 61 | 62 | #[test] 63 | fn stateful_list_test() { 64 | let items = vec!["one", "two", "three", "four", "five"]; 65 | 66 | let mut table = StatefulTable::with_items(items); 67 | assert_eq!(table.state.selected(), None); 68 | 69 | table.next(); 70 | assert_eq!(table.state.selected(), Some(0)); 71 | 72 | table.previous(); 73 | assert_eq!(table.state.selected(), Some(4)); 74 | 75 | table.next(); 76 | table.next(); 77 | table.next(); 78 | table.next(); 79 | table.next(); 80 | table.next(); 81 | assert_eq!(table.state.selected(), Some(0)); 82 | 83 | table.unselect(); 84 | assert_eq!(table.state.selected(), None); 85 | } 86 | } 87 | 88 | // pub fn items(&self) -> Result, Box> { 89 | // self.source.get_items() 90 | // } 91 | // 92 | // pub fn set_filter(&mut self, filter: F) { 93 | // self.filter = Some(filter) 94 | // } 95 | // 96 | // pub fn refresh_visible(&mut self) -> Result<(), Box> { 97 | // if self.filter.is_none() { 98 | // self.visible = self.items()?; 99 | // return Ok(()) 100 | // } 101 | // 102 | // self.visible.clear(); 103 | // let items = self.source.get_items()?; 104 | // 105 | // let filter = &self.filter.unwrap(); 106 | // for i in items { 107 | // if filter.apply(i) { 108 | // self.visible.push(i.clone()) 109 | // } 110 | // } 111 | // 112 | // Ok(()) 113 | // } 114 | 115 | // 116 | // #[cfg(test)] 117 | // mod test { 118 | // use crate::interactive::table::StatefulTable; 119 | // use crate::interactive::url_table_item::URLItem; 120 | // use bookmark_lib::record_filter::URLFilter; 121 | // use bookmark_lib::types::URLRecord; 122 | // 123 | // fn fix_url_items() -> Vec { 124 | // vec![ 125 | // URLItem::new(URLRecord::new("one", "one", "one", vec![])), 126 | // URLItem::new(URLRecord::new("two", "two", "two", vec![])), 127 | // URLItem::new(URLRecord::new("three", "three", "three", vec![])), 128 | // URLItem::new(URLRecord::new("four", "four", "four", vec![])), 129 | // URLItem::new(URLRecord::new("five", "five", "five", vec![])), 130 | // ] 131 | // } 132 | // 133 | // #[test] 134 | // fn test_stateful_list() { 135 | // let items = fix_url_items(); 136 | // 137 | // let mut table = StatefulTable::with_items(items.as_slice()); 138 | // assert_eq!(table.state.selected(), None); 139 | // 140 | // table.next(); 141 | // assert_eq!(table.state.selected(), Some(0)); 142 | // 143 | // table.previous(); 144 | // assert_eq!(table.state.selected(), Some(4)); 145 | // 146 | // table.next(); 147 | // table.next(); 148 | // table.next(); 149 | // table.next(); 150 | // table.next(); 151 | // table.next(); 152 | // assert_eq!(table.state.selected(), Some(0)); 153 | // 154 | // table.unselect(); 155 | // assert_eq!(table.state.selected(), None); 156 | // } 157 | // 158 | // struct FixedFilter { 159 | // matches: bool, 160 | // } 161 | // 162 | // impl FixedFilter { 163 | // fn new(matches: bool) -> FixedFilter { 164 | // FixedFilter { matches } 165 | // } 166 | // } 167 | // 168 | // impl URLFilter for FixedFilter { 169 | // fn matches(&self, _: &URLRecord) -> bool { 170 | // return self.matches; 171 | // } 172 | // } 173 | // 174 | // #[test] 175 | // fn test_prev_next_with_visibility() { 176 | // let match_filter = FixedFilter::new(true); 177 | // let do_not_match_filter = FixedFilter::new(false); 178 | // 179 | // let items = fix_url_items(); 180 | // let mut table = StatefulTable::with_items(items.as_slice()); 181 | // 182 | // assert_eq!(table.items.len(), table.visible.len()); 183 | // 184 | // table.items[1].filter(&do_not_match_filter); 185 | // table.refresh_visible(); 186 | // assert_eq!(table.items.len() - 1, table.visible.len()); 187 | // 188 | // table.items[0].filter(&do_not_match_filter); 189 | // table.items[0].filter(&do_not_match_filter); 190 | // table.items[2].filter(&do_not_match_filter); 191 | // table.refresh_visible(); 192 | // assert_eq!(table.items.len() - 3, table.visible.len()); 193 | // 194 | // table.items[0].filter(&match_filter); 195 | // table.items[0].filter(&match_filter); 196 | // table.refresh_visible(); 197 | // assert_eq!(table.items.len() - 2, table.visible.len()); 198 | // } 199 | // } 200 | -------------------------------------------------------------------------------- /src/bin/interactive/url_table_item.rs: -------------------------------------------------------------------------------- 1 | use crate::interactive::table::TableItem; 2 | use bookmark_lib::types::URLRecord; 3 | 4 | pub const DEFAULT_URL_COLS: [&str; 4] = ["Name", "URL", "Group", "Tags"]; 5 | 6 | pub type Columns = Vec; 7 | 8 | pub fn default_columns() -> Columns { 9 | (&DEFAULT_URL_COLS) 10 | .iter() 11 | .map(|s| s.to_string()) 12 | .collect::() 13 | } 14 | 15 | #[derive(Clone, Debug)] 16 | pub struct URLItem { 17 | url: URLRecord, 18 | row: Vec, 19 | } 20 | 21 | impl URLItem { 22 | pub fn new(record: URLRecord, cols: Option<&Columns>) -> URLItem { 23 | URLItem { 24 | url: record.clone(), 25 | row: url_to_row( 26 | &record, 27 | cols.unwrap_or( 28 | &DEFAULT_URL_COLS 29 | .iter() 30 | .map(|s| s.to_string()) 31 | .collect::(), 32 | ), 33 | ), 34 | } 35 | } 36 | 37 | pub fn from_vec(records: Vec, cols: Option<&Columns>) -> Vec { 38 | records 39 | .iter() 40 | .map(|u| URLItem::new(u.clone(), cols)) 41 | .collect() 42 | } 43 | 44 | pub fn url(&self) -> String { 45 | self.url.url.clone() 46 | } 47 | } 48 | 49 | impl TableItem for URLItem { 50 | fn row(&self) -> &Vec { 51 | &self.row 52 | } 53 | 54 | fn id(&self) -> String { 55 | self.url.id.clone() 56 | } 57 | } 58 | 59 | fn url_to_row(record: &URLRecord, cols: &Columns) -> Vec { 60 | let mut vals = vec![]; 61 | 62 | for c in cols { 63 | let col_name = c.trim().to_lowercase(); 64 | 65 | match col_name.as_str() { 66 | "id" => vals.push(record.id.clone()), 67 | "name" => vals.push(record.name.clone()), 68 | "url" => vals.push(record.url.clone()), 69 | "group" => vals.push(record.group.clone()), 70 | "tags" => vals.push(record.tags_as_string()), 71 | _ => {} 72 | } 73 | } 74 | 75 | vals 76 | } 77 | 78 | #[cfg(test)] 79 | mod test { 80 | use crate::interactive::helpers::to_string; 81 | use crate::interactive::table::TableItem; 82 | use crate::interactive::url_table_item::{default_columns, Columns, URLItem}; 83 | use bookmark_lib::types::URLRecord; 84 | 85 | struct TestCase<'a> { 86 | url_record: URLRecord, 87 | columns: Option<&'a Columns>, 88 | expected_row: Vec, 89 | } 90 | 91 | #[test] 92 | fn test_url_item() { 93 | let record = URLRecord::new("url1", "name1", "group1", vec!["tag1", "tag1.2"]); 94 | let cols = to_string(vec!["ID", "Name", " Tags "]); 95 | 96 | let items = vec![ 97 | TestCase { 98 | url_record: record.clone(), 99 | expected_row: to_string(vec!["name1", "url1", "group1", "tag1, tag1.2"]), 100 | columns: None, 101 | }, 102 | TestCase { 103 | url_record: record.clone(), 104 | expected_row: to_string(vec![&record.id, "name1", "tag1, tag1.2"]), 105 | columns: Some(&cols), 106 | }, 107 | TestCase { 108 | url_record: URLRecord::new("url2", "name2", "group2", vec!["tag2", "tag2.2"]), 109 | expected_row: to_string(vec!["name2", "url2", "group2", "tag2, tag2.2"]), 110 | columns: None, 111 | }, 112 | TestCase { 113 | url_record: URLRecord::new("url3", "name3", "group3", vec!["tag3", "tag3.2"]), 114 | expected_row: to_string(vec!["name3", "url3", "group3", "tag3, tag3.2"]), 115 | columns: None, 116 | }, 117 | TestCase { 118 | url_record: URLRecord::new("url4", "name4", "group4", Vec::::new()), 119 | expected_row: to_string(vec!["name4", "url4", "group4", ""]), 120 | columns: None, 121 | }, 122 | TestCase { 123 | url_record: URLRecord::new("url5", "name5", "group5", vec!["tag", "with space"]), 124 | expected_row: to_string(vec!["name5", "url5", "group5", "tag, \"with space\""]), 125 | columns: None, 126 | }, 127 | ]; 128 | 129 | for item in items { 130 | let table_item = URLItem::new(item.url_record, item.columns.clone()); 131 | let row = table_item.row(); 132 | assert_eq!(&item.expected_row, row); 133 | } 134 | } 135 | 136 | #[test] 137 | fn test_default_columns() { 138 | let def_cols = default_columns(); 139 | 140 | let expected_cols = vec![ 141 | "Name".to_string(), 142 | "URL".to_string(), 143 | "Group".to_string(), 144 | "Tags".to_string(), 145 | ]; 146 | 147 | assert_eq!(def_cols, expected_cols) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/bin/interactive/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod rect; 2 | -------------------------------------------------------------------------------- /src/bin/interactive/widgets/rect.rs: -------------------------------------------------------------------------------- 1 | use crate::interactive::helpers::{horizontal_layout, vertical_layout}; 2 | use ratatui::layout::Rect; 3 | 4 | /// helper function to create a centered rect with a specified size 5 | pub(crate) fn centered_fixed_rect(size_x: u16, size_y: u16, r: Rect) -> Rect { 6 | let popup_layout = vertical_layout(vec![ 7 | (r.height - size_y) / 2, 8 | size_y, 9 | (r.height - size_y) / 2, 10 | ]) 11 | .split(r); 12 | 13 | horizontal_layout(vec![(r.width - size_x) / 2, size_x, (r.width - size_x) / 2]) 14 | .split(popup_layout[1])[1] 15 | } 16 | 17 | #[cfg(test)] 18 | mod test { 19 | use crate::interactive::widgets::rect::centered_fixed_rect; 20 | use ratatui::layout::Rect; 21 | 22 | #[test] 23 | fn test_create_centered_fixed_rect() { 24 | let base = Rect::new(0, 0, 10, 10); 25 | let rect = centered_fixed_rect(6, 4, base); 26 | 27 | assert_eq!(rect.width, 6); 28 | assert_eq!(rect.height, 4); 29 | assert_eq!(rect.x, 2); 30 | assert_eq!(rect.y, 3); 31 | 32 | let rect = centered_fixed_rect(7, 3, base); 33 | 34 | assert_eq!(rect.width, 7); 35 | assert_eq!(rect.height, 3); 36 | assert_eq!(rect.x, 1); 37 | assert_eq!(rect.y, 3); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/bin/main.rs: -------------------------------------------------------------------------------- 1 | extern crate clap; 2 | use clap::{Arg, ArgAction, ArgMatches, Command}; 3 | 4 | use crate::interactive::interactive_mode::enter_interactive_mode; 5 | use crate::interactive::subcommand::add; 6 | 7 | use bookmark_lib::registry::{URLRegistry, DEFAULT_GROUP}; 8 | use bookmark_lib::storage::FileStorage; 9 | use bookmark_lib::Registry; 10 | 11 | use bookmark_lib::filters::{Filter, GroupFilter, NoopFilter, TagsFilter}; 12 | use bookmark_lib::sort::{SortBy, SortConfig}; 13 | use std::str::FromStr; 14 | 15 | mod cmd; 16 | mod display; 17 | mod interactive; 18 | 19 | const URLS_V0_0_X_DEFAULT_FILE_PATH: &str = ".bookmark-cli/urls.json"; 20 | 21 | const URLS_DEFAULT_FILE_PATH: &str = ".bookmark/urls_v0.1.json"; 22 | 23 | const VERSION_V0_0_X: &str = " v0.0.x"; 24 | 25 | fn main() { 26 | let urls_v0_0_x_default_full_path = path_with_homedir(URLS_V0_0_X_DEFAULT_FILE_PATH) 27 | .expect("Failed to get default v0_0_x path"); 28 | 29 | let cmd = Command::new("Bookmark") 30 | .version(env!("CARGO_PKG_VERSION")) 31 | .author("Szymon Gibała ") 32 | .about("Group, tag and quickly access your URLs from terminal") 33 | .arg(Arg::new("file") 34 | .short('f') 35 | .long("file") 36 | .value_name("FILE") 37 | .required(false) 38 | .help("Path to file storing the URLs") 39 | .action(ArgAction::Set) 40 | ) 41 | .subcommand(Command::new(cmd::GROUP_SUB_CMD) 42 | .about("Manage URL groups") 43 | .subcommand(Command::new(cmd::GROUP_LIST_CMD) 44 | .about("List groups") 45 | ) 46 | ) 47 | .subcommand(Command::new(cmd::ADD_SUB_CMD) 48 | .about("Add bookmark URL") 49 | .arg(Arg::new("name") 50 | .help("Bookmark name") 51 | .index(1) 52 | ) 53 | .arg(Arg::new("url") 54 | .help("URL address") 55 | .index(2) 56 | ) 57 | .arg(Arg::new("tag") 58 | .help("URL tags. Accepts multiple values: url add [NAME] [URL] -t tag1 -t tag2") 59 | .required(false) 60 | .short('t') 61 | .long("tag") 62 | .action(ArgAction::Append) 63 | .number_of_values(1) 64 | // TODO: add validator to exclude forbidden chars (like ,) 65 | ) 66 | .arg(Arg::new("group") 67 | .help("Group to which URL should be assigned") 68 | .required(false) 69 | .action(ArgAction::Set) 70 | .short('g') 71 | .long("group")) 72 | ) 73 | .subcommand(Command::new(cmd::LIST_SUB_CMD) 74 | .alias("ls") 75 | .about("List bookmarks ") 76 | .arg(Arg::new("group") // If not specified use default or global 77 | .help("Group from which URLs should be listed") 78 | .required(false) 79 | .action(ArgAction::Set) 80 | .short('g') 81 | .long("group")) 82 | .arg(Arg::new("tag") 83 | .help("URL tags. Accepts multiple values: url add [NAME] [URL] -t tag1 -t tag2") 84 | .required(false) 85 | .short('t') 86 | .long("tag") 87 | .action(ArgAction::Append) 88 | .number_of_values(1)) 89 | .arg(Arg::new("sort") 90 | .help("Specifies to sort bookmarks by one of the columns: [name, url, group]") 91 | .required(false) 92 | .long("sort") 93 | .action(ArgAction::Set) 94 | .number_of_values(1)) 95 | ) 96 | .subcommand(Command::new(cmd::DELETE_SUB_CMD) 97 | .about("Delete bookmark") 98 | .arg(Arg::new("id") 99 | .help("Bookmark id to delete") 100 | .required(true) 101 | .index(1) 102 | ) 103 | ) 104 | .subcommand(Command::new(cmd::TAG_SUB_CMD) 105 | .about("Add tag to bookmark") 106 | // .usage("bookmark tag [ID] [TAG]") 107 | // .override_usage(usage) 108 | .arg(Arg::new("id") 109 | .help("Bookmark id to tag") 110 | .required(true) 111 | .index(1)) 112 | .arg(Arg::new("tag") 113 | .help("Tag to add") 114 | .required(true) 115 | .index(2) 116 | ) 117 | ) 118 | .subcommand(Command::new(cmd::UNTAG_SUB_CMD) 119 | .about("Remove tag from bookmark") 120 | // .usage("bookmark untag [ID] [TAG]") 121 | .arg(Arg::new("id") 122 | .help("Bookmark id to untag") 123 | .required(true) 124 | .index(1)) 125 | .arg(Arg::new("tag") 126 | .help("Tag to remove") 127 | .required(true) 128 | .index(2) 129 | ) 130 | ) 131 | .subcommand(Command::new(cmd::CHANGE_GROUP_SUB_CMD) 132 | .about("Change group of the bookmark") 133 | // .usage("bookmark chg [ID] [GROUP]") 134 | .alias(cmd::CHANGE_GROUP_SUB_CMD_ALIAS) 135 | .arg(Arg::new("id") 136 | .help("Bookmark id to change the group") 137 | .required(true) 138 | .index(1)) 139 | .arg(Arg::new("group") 140 | .help("New group") 141 | .required(true) 142 | .index(2) 143 | ) 144 | ) 145 | .subcommand(Command::new(cmd::CHANGE_NAME_SUB_CMD) 146 | .about("Change name of the bookmark") 147 | // .usage("bookmark chn [ID] [NAME]") 148 | .alias(cmd::CHANGE_NAME_SUB_CMD_ALIAS) 149 | .arg(Arg::new("id") 150 | .help("Bookmark id to change the name") 151 | .required(true) 152 | .index(1)) 153 | .arg(Arg::new("name") 154 | .help("New name") 155 | .required(true) 156 | .index(2) 157 | ) 158 | ) 159 | .subcommand(Command::new(cmd::CHANGE_URL_SUB_CMD) 160 | .about("Change URL of the bookmark") 161 | // .usage("bookmark chu [ID] [URL]") 162 | .alias(cmd::CHANGE_URL_SUB_CMD_ALIAS) 163 | .arg(Arg::new("id") 164 | .help("Bookmark id to change the URL") 165 | .required(true) 166 | .index(1)) 167 | .arg(Arg::new("url") 168 | .help("New URL") 169 | .required(true) 170 | .index(2) 171 | ) 172 | ) 173 | // TODO: I think I can drop it at this point 174 | .subcommand(Command::new(cmd::IMPORT_SUB_CMD) 175 | .about("Imports bookmarks from the previous versions") 176 | .arg(Arg::new("version") 177 | .help(format!("Version from which URLs should be imported. One of: {}", VERSION_V0_0_X)) 178 | .required(false) 179 | .action(ArgAction::Set) 180 | .short('v') 181 | .long("version") 182 | .default_value(VERSION_V0_0_X)) 183 | .arg(Arg::new("old-file") 184 | .help("Path to the file storing URLs from previous version") 185 | .required(false) 186 | .action(ArgAction::Set) 187 | .long("old-file") 188 | .default_value(urls_v0_0_x_default_full_path) 189 | ) 190 | ); 191 | 192 | let matches = cmd.get_matches(); 193 | 194 | let file_path = match matches.get_one::("file") { 195 | Some(t) => t.to_string(), 196 | None => match get_default_registry_file_path() { 197 | Some(path) => path, 198 | None => panic!("Failed to get default file path"), 199 | }, 200 | }; 201 | 202 | let application = Application::new_file_based_registry(file_path); 203 | 204 | match matches.subcommand() { 205 | Some((cmd::GROUP_SUB_CMD, group_matches)) => { 206 | application.group_sub_cmd(group_matches); 207 | } 208 | Some((cmd::ADD_SUB_CMD, add_matches)) => { 209 | application.add_sub_cmd(add_matches); 210 | } 211 | Some((cmd::LIST_SUB_CMD, list_matches)) => { 212 | application.list_sub_cmd(list_matches); 213 | } 214 | Some((cmd::DELETE_SUB_CMD, delete_matches)) => { 215 | application.delete_sub_cmd(delete_matches); 216 | } 217 | Some((cmd::IMPORT_SUB_CMD, import_matches)) => { 218 | application.import_sub_cmd(import_matches); 219 | } 220 | Some((cmd::TAG_SUB_CMD, tag_matches)) => { 221 | application.tag_sub_cmd(tag_matches); 222 | } 223 | Some((cmd::UNTAG_SUB_CMD, untag_matches)) => { 224 | application.untag_sub_cmd(untag_matches); 225 | } 226 | Some((cmd::CHANGE_GROUP_SUB_CMD, chg_matches)) => { 227 | application.change_group_sub_cmd(chg_matches); 228 | } 229 | Some((cmd::CHANGE_NAME_SUB_CMD, chn_matches)) => { 230 | application.change_name_sub_cmd(chn_matches); 231 | } 232 | Some((cmd::CHANGE_URL_SUB_CMD, chu_matches)) => { 233 | application.change_url_sub_cmd(chu_matches); 234 | } 235 | None => { 236 | if let Err(err) = enter_interactive_mode(application.registry) { 237 | println!( 238 | "Error: failed to enter interactive mode: {}", 239 | err.to_string() 240 | ) 241 | }; 242 | } 243 | _ => println!("Error: subcommand not found"), 244 | } 245 | } 246 | 247 | fn get_default_registry_file_path() -> Option { 248 | path_with_homedir(URLS_DEFAULT_FILE_PATH) 249 | } 250 | 251 | fn path_with_homedir(path: &str) -> Option { 252 | match dirs::home_dir() { 253 | Some(home_dir) => home_dir.join(path).to_str().map(|s: &str| s.to_string()), 254 | None => None, 255 | } 256 | } 257 | 258 | struct Application { 259 | registry: T, 260 | } 261 | 262 | impl Application> { 263 | pub fn new_file_based_registry(file_path: String) -> Application> { 264 | Application { 265 | registry: URLRegistry::new_file_based(file_path), 266 | } 267 | } 268 | } 269 | 270 | impl Application { 271 | pub fn group_sub_cmd(&self, matches: &ArgMatches) { 272 | self.list_groups_cmd(matches) 273 | } 274 | 275 | fn list_groups_cmd(&self, _matches: &ArgMatches) { 276 | match self.registry.list_groups() { 277 | Ok(groups) => { 278 | for g in &groups { 279 | println!("{}", g); 280 | } 281 | } 282 | Err(why) => println!("Error: failed to list groups: {}", why), 283 | } 284 | } 285 | 286 | pub fn add_sub_cmd(&self, matches: &ArgMatches) { 287 | let url_name = matches.get_one::("name"); 288 | let url = matches.get_one::("url"); 289 | let group = matches 290 | .get_one::("group") 291 | .map(|g| g.to_string()) 292 | .unwrap_or(DEFAULT_GROUP.to_string()); 293 | 294 | let tags: Vec = get_multiple_values(matches, "tag") 295 | .unwrap_or_default() 296 | .iter() 297 | .map(|s| s.to_string()) 298 | .collect(); 299 | 300 | let mut add_data = add::AddData::new( 301 | url_name.unwrap_or(&"".to_string()).as_str(), 302 | url.unwrap_or(&"".to_string()).as_str(), 303 | &group, 304 | &tags, 305 | ); 306 | 307 | if url_name.is_none() || url.is_none() { 308 | add_data = add::interactive_add(add_data).expect("err"); 309 | } 310 | 311 | match self.registry.create( 312 | &add_data.name, 313 | &add_data.url, 314 | Some(&add_data.group), 315 | add_data.tags, 316 | ) { 317 | Ok(url_record) => println!( 318 | "Added url '{}': '{}' to '{}' group", 319 | url_record.name, url_record.url, url_record.group 320 | ), 321 | Err(why) => println!( 322 | "Error adding url '{}' with name '{}': {}", 323 | add_data.url, add_data.name, why 324 | ), 325 | } 326 | } 327 | 328 | pub fn list_sub_cmd(&self, matches: &ArgMatches) { 329 | let noop_filter: Box = Box::new(NoopFilter::default()); 330 | 331 | let group_filter: Box = matches 332 | .get_one::("group") 333 | .map(|g| { 334 | let f: Box = Box::new(GroupFilter::new(g)); 335 | f 336 | }) 337 | .unwrap_or(noop_filter); 338 | 339 | let tags_filter: Box = get_multiple_values(matches, "tag") 340 | .map(|t| { 341 | let f: Box = Box::new(TagsFilter::new(t)); 342 | f 343 | }) 344 | .unwrap_or(group_filter); 345 | 346 | let sort_cfg = matches.get_one::("sort").map(|val| { 347 | let sort_by = SortBy::from_str(val).expect("Invalid sort column"); 348 | SortConfig::new_by(sort_by) 349 | }); 350 | 351 | // TODO: support output as json? 352 | match self 353 | .registry 354 | .list_urls(Some(tags_filter.as_ref()), sort_cfg) 355 | { 356 | Ok(urls) => { 357 | display::display_urls(urls); 358 | } 359 | Err(why) => { 360 | println!("Error getting URLs: {}", why); 361 | } 362 | } 363 | } 364 | 365 | pub fn delete_sub_cmd(&self, matches: &ArgMatches) { 366 | let id = matches 367 | .get_one::("id") 368 | .expect("Error: id not provided"); 369 | 370 | match self.registry.delete(id) { 371 | Ok(deleted) => { 372 | if deleted { 373 | println!("URL '{}' removed", id) 374 | } else { 375 | println!("URL '{}' not found", id) 376 | } 377 | } 378 | Err(why) => println!("Error deleting '{}' URL: {}", id, why), 379 | } 380 | } 381 | 382 | pub fn import_sub_cmd(&self, matches: &ArgMatches) { 383 | let version = matches 384 | .get_one::("version") 385 | .expect("Version from which to import not provided"); 386 | let old_file = matches 387 | .get_one::("old-file") 388 | .expect("Old version file path not provided"); 389 | 390 | match version.as_str() { 391 | VERSION_V0_0_X => match self.registry.import_from_v_0_0_x(old_file) { 392 | Ok(_imported) => println!("Successfully imported bookmarks!"), 393 | Err(why) => println!( 394 | "Error importing bookmarks from file '{}': {} ", 395 | old_file, why 396 | ), 397 | }, 398 | v => { 399 | println!("Error importing bookmarks, version '{}' not recognized. Version have to be one of '{}'", v, VERSION_V0_0_X); 400 | } 401 | } 402 | } 403 | 404 | pub fn tag_sub_cmd(&self, matches: &ArgMatches) { 405 | let id = matches 406 | .get_one::("id") 407 | .expect("Error: bookmark id not provided"); 408 | let tag = matches 409 | .get_one::("tag") 410 | .expect("Error: tag not provided"); 411 | 412 | match self.registry.tag(id, tag) { 413 | Ok(record) => match record { 414 | Some(r) => println!("Bookmark '{}' tagged with '{}'", r.id, tag), 415 | None => println!("Error: bookmark with id '{}' not found", id), 416 | }, 417 | Err(why) => println!("Error: failed to tag bookmark '{}': {} ", id, why), 418 | } 419 | } 420 | 421 | pub fn untag_sub_cmd(&self, matches: &ArgMatches) { 422 | let id = matches 423 | .get_one::("id") 424 | .expect("Error: bookmark id not provided"); 425 | let tag = matches 426 | .get_one::("tag") 427 | .expect("Error: tag not provided"); 428 | 429 | match self.registry.untag(id, tag) { 430 | Ok(record) => match record { 431 | Some(r) => println!("Tag '{}' removed from bookmark '{}'", tag, r.id), 432 | None => println!("Error: bookmark with id '{}' not found", id), 433 | }, 434 | Err(why) => println!("Error: failed to untag bookmark '{}': {} ", id, why), 435 | } 436 | } 437 | 438 | pub fn change_group_sub_cmd(&self, matches: &ArgMatches) { 439 | let id = matches 440 | .get_one::("id") 441 | .expect("Error: bookmark id not provided"); 442 | let group = matches 443 | .get_one::("group") 444 | .expect("Error: group not provided"); 445 | 446 | match self.registry.change_group(id, group) { 447 | Ok(record) => match record { 448 | Some(r) => println!("Bookmark '{}' group change to '{}'", r.id, group), 449 | None => println!("Error: bookmark with id '{}' not found", id), 450 | }, 451 | Err(why) => println!( 452 | "Error: failed to change group of bookmark '{}': {} ", 453 | id, why 454 | ), 455 | } 456 | } 457 | 458 | pub fn change_name_sub_cmd(&self, matches: &ArgMatches) { 459 | let id = matches 460 | .get_one::("id") 461 | .expect("Error: bookmark id not provided"); 462 | let name = matches 463 | .get_one::("name") 464 | .expect("Error: name not provided"); 465 | 466 | match self.registry.change_name(id, name) { 467 | Ok(record) => match record { 468 | Some(r) => println!("Bookmark '{}' name change to '{}'", r.id, name), 469 | None => println!("Error: bookmark with id '{}' not found", id), 470 | }, 471 | Err(why) => println!( 472 | "Error: failed to change name of bookmark '{}': {} ", 473 | id, why 474 | ), 475 | } 476 | } 477 | 478 | pub fn change_url_sub_cmd(&self, matches: &ArgMatches) { 479 | let id = matches 480 | .get_one::("id") 481 | .expect("Error: bookmark id not provided"); 482 | let url = matches 483 | .get_one::("url") 484 | .expect("Error: url not provided"); 485 | 486 | match self.registry.change_url(id, url) { 487 | Ok(record) => match record { 488 | Some(r) => println!("Bookmark '{}' url change to '{}'", r.id, url), 489 | None => println!("Error: bookmark with id '{}' not found", id), 490 | }, 491 | Err(why) => println!("Error: failed to change url of bookmark '{}': {} ", id, why), 492 | } 493 | } 494 | } 495 | 496 | fn get_multiple_values<'a>(matches: &'a ArgMatches, name: &str) -> Option> { 497 | let values = matches.get_many::(name); 498 | values.map(|vals| { 499 | vals.into_iter() 500 | .map(|s| s.as_str()) 501 | // .filter_map(|s| s) 502 | .collect() 503 | }) 504 | } 505 | -------------------------------------------------------------------------------- /src/lib/filters.rs: -------------------------------------------------------------------------------- 1 | use crate::types::URLRecord; 2 | 3 | pub trait Filter { 4 | fn matches(&self, record: &URLRecord) -> bool; 5 | fn chain(self, filter: Box) -> Box; 6 | } 7 | 8 | #[derive(Default)] 9 | pub struct NoopFilter {} 10 | 11 | impl Filter for NoopFilter { 12 | fn matches(&self, _record: &URLRecord) -> bool { 13 | true 14 | } 15 | fn chain(self, filter: Box) -> Box { 16 | Box::new(FilterSet::new_combined(vec![filter])) 17 | } 18 | } 19 | 20 | /// UnorderedWordSetFilter searches for individual words in the search phrase 21 | /// Only records containing all words will match the filter 22 | pub struct UnorderedWordSetFilter { 23 | phrase: String, 24 | } 25 | 26 | impl Filter for UnorderedWordSetFilter { 27 | fn matches(&self, record: &URLRecord) -> bool { 28 | if self.phrase == "" { 29 | return true; 30 | } 31 | 32 | for p in self.phrase.split(' ').filter(|p| !p.is_empty()) { 33 | let word = p.to_lowercase(); 34 | 35 | // Check if any part matches the word 36 | let matches = record.name.to_lowercase().contains(&word) 37 | || record.url.to_lowercase().contains(&word) 38 | || record.group.to_lowercase().contains(&word) 39 | || tag_matches(record, &word); 40 | 41 | if !matches { 42 | return false; 43 | } 44 | } 45 | 46 | true 47 | } 48 | 49 | fn chain(self, filter: Box) -> Box { 50 | Box::new(FilterSet::new_combined(vec![Box::new(self), filter])) 51 | } 52 | } 53 | 54 | impl UnorderedWordSetFilter { 55 | pub fn new(phrase: &str) -> UnorderedWordSetFilter { 56 | UnorderedWordSetFilter { 57 | phrase: phrase.to_string(), 58 | } 59 | } 60 | } 61 | 62 | pub struct FilterSet { 63 | filters: Vec>, 64 | } 65 | 66 | // TODO: add builder for combined filters? 67 | 68 | impl FilterSet { 69 | pub fn new_combined_for_phrase(phrase: &str) -> FilterSet { 70 | FilterSet { 71 | filters: vec![ 72 | Box::new(PhraseFilter::new_name_filter(phrase)), 73 | Box::new(PhraseFilter::new_url_filter(phrase)), 74 | Box::new(PhraseFilter::new_group_filter(phrase)), 75 | Box::new(PhraseFilter::new_tag_filter(phrase)), 76 | ], 77 | } 78 | } 79 | 80 | pub fn new_combined(filters: Vec>) -> FilterSet { 81 | FilterSet { filters } 82 | } 83 | } 84 | 85 | impl Filter for FilterSet { 86 | fn matches(&self, record: &URLRecord) -> bool { 87 | for f in &self.filters { 88 | if f.matches(record) { 89 | return true; 90 | } 91 | } 92 | false 93 | } 94 | 95 | fn chain(self, filter: Box) -> Box { 96 | Box::new(FilterSet::new_combined(vec![Box::new(self), filter])) 97 | } 98 | } 99 | 100 | pub struct GroupFilter { 101 | group: String, 102 | } 103 | 104 | impl Filter for GroupFilter { 105 | fn matches(&self, record: &URLRecord) -> bool { 106 | record.group == self.group 107 | } 108 | fn chain(self, filter: Box) -> Box { 109 | Box::new(FilterSet::new_combined(vec![Box::new(self), filter])) 110 | } 111 | } 112 | 113 | impl GroupFilter { 114 | pub fn new(group: &str) -> GroupFilter { 115 | GroupFilter { 116 | group: group.to_string(), 117 | } 118 | } 119 | } 120 | 121 | pub struct TagsFilter { 122 | tags: Vec, 123 | } 124 | 125 | impl Filter for TagsFilter { 126 | fn matches(&self, record: &URLRecord) -> bool { 127 | for t in &self.tags { 128 | if record.tags.contains_key(t) { 129 | return true; 130 | } 131 | } 132 | false 133 | } 134 | fn chain(self, filter: Box) -> Box { 135 | Box::new(FilterSet::new_combined(vec![Box::new(self), filter])) 136 | } 137 | } 138 | 139 | impl TagsFilter { 140 | pub fn new(tags: Vec<&str>) -> TagsFilter { 141 | TagsFilter { 142 | tags: tags.iter().map(|t| t.to_string()).collect(), 143 | } 144 | } 145 | } 146 | 147 | enum SearchElement { 148 | Name, 149 | URL, 150 | Group, 151 | Tag, 152 | } 153 | 154 | /// Phrase filter filters Bookmarks by specific element 155 | /// To match, the element needs to contain the the phrase (case insensitive) 156 | pub struct PhraseFilter { 157 | phrase: String, 158 | element: SearchElement, 159 | } 160 | 161 | impl Filter for PhraseFilter { 162 | fn matches(&self, record: &URLRecord) -> bool { 163 | match &self.element { 164 | SearchElement::Name => record.name.to_lowercase().contains(&self.phrase), 165 | SearchElement::URL => record.url.to_lowercase().contains(&self.phrase), 166 | SearchElement::Group => record.group.to_lowercase().contains(&self.phrase), 167 | SearchElement::Tag => tag_matches(record, &self.phrase), 168 | } 169 | } 170 | 171 | fn chain(self, filter: Box) -> Box { 172 | Box::new(FilterSet::new_combined(vec![Box::new(self), filter])) 173 | } 174 | } 175 | 176 | impl PhraseFilter { 177 | pub fn new_name_filter(phrase: &str) -> PhraseFilter { 178 | PhraseFilter { 179 | phrase: phrase.to_lowercase(), 180 | element: SearchElement::Name, 181 | } 182 | } 183 | 184 | pub fn new_url_filter(phrase: &str) -> PhraseFilter { 185 | PhraseFilter { 186 | phrase: phrase.to_lowercase(), 187 | element: SearchElement::URL, 188 | } 189 | } 190 | 191 | pub fn new_group_filter(phrase: &str) -> PhraseFilter { 192 | PhraseFilter { 193 | phrase: phrase.to_lowercase(), 194 | element: SearchElement::Group, 195 | } 196 | } 197 | 198 | pub fn new_tag_filter(phrase: &str) -> PhraseFilter { 199 | PhraseFilter { 200 | phrase: phrase.to_lowercase(), 201 | element: SearchElement::Tag, 202 | } 203 | } 204 | } 205 | 206 | fn tag_matches(record: &URLRecord, word: &str) -> bool { 207 | for t in record.tags.keys() { 208 | if t.to_lowercase().contains(word) { 209 | return true; 210 | } 211 | } 212 | false 213 | } 214 | 215 | #[cfg(test)] 216 | mod test { 217 | use crate::filters::{Filter, FilterSet, UnorderedWordSetFilter}; 218 | use crate::types::URLRecord; 219 | 220 | #[test] 221 | fn test_unordered_word_ser_filter() { 222 | let test_set = vec![ 223 | URLRecord::new( 224 | "http://urlAbcd.com", 225 | "first url", 226 | "default", 227 | vec!["pop", "with space"], 228 | ), 229 | URLRecord::new( 230 | "http://test123.com", 231 | "catchy name", 232 | "super group", 233 | vec!["pop", "with-dash"], 234 | ), 235 | URLRecord::new("http://another.com", "poppy", "group", Vec::::new()), 236 | ]; 237 | 238 | struct TestCase { 239 | phrase: String, 240 | matches: Vec, 241 | } 242 | 243 | let test_cases = vec![ 244 | TestCase { 245 | phrase: "abcd url default pop".to_string(), 246 | matches: vec![true, false, false], 247 | }, 248 | TestCase { 249 | phrase: "pop http com".to_string(), 250 | matches: vec![true, true, true], 251 | }, 252 | TestCase { 253 | phrase: "http complicated".to_string(), 254 | matches: vec![false, false, false], 255 | }, 256 | ]; 257 | 258 | for test in test_cases { 259 | println!("Phrase: {}", test.phrase); 260 | 261 | let filter: UnorderedWordSetFilter = UnorderedWordSetFilter::new(test.phrase.as_str()); 262 | 263 | for i in 0..test_set.len() { 264 | println!("URL: {}", &test_set[i]); 265 | assert_eq!(filter.matches(&test_set[i]), test.matches[i]) 266 | } 267 | } 268 | } 269 | 270 | #[test] 271 | fn test_combined_phrase_filters() { 272 | let test_set = vec![ 273 | URLRecord::new( 274 | "http://urlAbcd.com", 275 | "first url", 276 | "default", 277 | vec!["pop", "with space"], 278 | ), 279 | URLRecord::new("http://another.com", "second ABCD", "default", vec!["pop"]), 280 | URLRecord::new( 281 | "http://another.com", 282 | "third with space", 283 | "group-abcd", 284 | vec!["pop"], 285 | ), 286 | URLRecord::new( 287 | "http://another.com", 288 | "fourth", 289 | "default", 290 | vec!["pop", "tag-abcd"], 291 | ), 292 | URLRecord::new( 293 | "http://acbd.com", 294 | "fifth with space", 295 | "default", 296 | vec!["pop", "another"], 297 | ), 298 | ]; 299 | 300 | struct TestCase { 301 | phrase: String, 302 | matches: Vec, 303 | } 304 | 305 | let test_cases = vec![ 306 | TestCase { 307 | phrase: "abcd".to_string(), 308 | matches: vec![true, true, true, true, false], 309 | }, 310 | TestCase { 311 | phrase: "Another".to_string(), 312 | matches: vec![false, true, true, true, true], 313 | }, 314 | TestCase { 315 | phrase: "pop".to_string(), 316 | matches: vec![true, true, true, true, true], 317 | }, 318 | TestCase { 319 | phrase: "third".to_string(), 320 | matches: vec![false, false, true, false, false], 321 | }, 322 | TestCase { 323 | phrase: "with space".to_string(), 324 | matches: vec![true, false, true, false, true], 325 | }, 326 | TestCase { 327 | phrase: "non existent".to_string(), 328 | matches: vec![false, false, false, false, false], 329 | }, 330 | ]; 331 | 332 | for test in test_cases { 333 | println!("Phrase: {}", test.phrase); 334 | 335 | let combined_filter: FilterSet = 336 | FilterSet::new_combined_for_phrase(test.phrase.as_str()); 337 | 338 | for i in 0..test_set.len() { 339 | println!("URL: {}", &test_set[i]); 340 | assert_eq!(combined_filter.matches(&test_set[i]), test.matches[i]) 341 | } 342 | } 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /src/lib/import/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod v0_0_x; 2 | -------------------------------------------------------------------------------- /src/lib/import/v0_0_x.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::collections::HashMap; 3 | use std::fmt; 4 | 5 | #[derive(Serialize, Deserialize)] 6 | pub struct URLRegistry { 7 | pub urls: URLs, 8 | } 9 | 10 | #[derive(Serialize, Deserialize, Debug)] 11 | pub struct URLGroups { 12 | pub items: Vec, 13 | } 14 | 15 | #[derive(Serialize, Deserialize, Debug)] 16 | pub struct URLGroup { 17 | pub name: String, 18 | } 19 | 20 | impl URLGroup { 21 | pub fn new(name: String) -> URLGroup { 22 | URLGroup { name } 23 | } 24 | } 25 | 26 | #[derive(Serialize, Deserialize)] 27 | pub struct URLs { 28 | pub items: Vec, 29 | } 30 | 31 | #[derive(Serialize, Deserialize, Clone, Debug)] 32 | pub struct URLRecord { 33 | pub url: String, 34 | pub name: String, 35 | pub group: String, 36 | pub tags: HashMap, 37 | } 38 | 39 | impl URLRecord { 40 | pub fn new(url: &str, name: &str, group: &str, tags_vec: Vec<&str>) -> URLRecord { 41 | let mut tags: HashMap = HashMap::new(); 42 | for t in tags_vec { 43 | tags.insert(t.to_string(), true); 44 | } 45 | 46 | URLRecord { 47 | url: url.to_string(), 48 | name: name.to_string(), 49 | group: group.to_string(), 50 | tags, 51 | } 52 | } 53 | } 54 | 55 | impl fmt::Display for URLRecord { 56 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 57 | write!( 58 | f, 59 | "Name: {}, URL: {}, Group: {}, Tags: {:?}", 60 | self.name, self.url, self.group, self.tags 61 | ) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/lib/lib.rs: -------------------------------------------------------------------------------- 1 | use crate::filters::Filter; 2 | use crate::import::v0_0_x; 3 | use crate::sort::SortConfig; 4 | use crate::types::URLRecord; 5 | 6 | pub mod filters; 7 | pub mod registry; 8 | pub mod storage; 9 | pub mod types; 10 | 11 | pub mod import; 12 | 13 | pub mod sort; 14 | mod util; 15 | 16 | pub trait Registry: RegistryReader + Importer { 17 | fn create( 18 | &self, 19 | name: &str, 20 | url: &str, 21 | group: Option<&str>, 22 | tags: Vec, 23 | ) -> Result>; 24 | 25 | fn add(&self, record: URLRecord) -> Result>; 26 | 27 | fn delete(&self, id: &str) -> Result>; 28 | 29 | fn list_groups(&self) -> Result, Box>; 30 | 31 | fn tag(&self, id: &str, tag: &str) -> Result, Box>; 32 | 33 | fn untag(&self, id: &str, tag: &str) -> Result, Box>; 34 | 35 | fn change_group( 36 | &self, 37 | id: &str, 38 | group: &str, 39 | ) -> Result, Box>; 40 | 41 | fn change_name( 42 | &self, 43 | id: &str, 44 | name: &str, 45 | ) -> Result, Box>; 46 | 47 | fn change_url( 48 | &self, 49 | id: &str, 50 | url: &str, 51 | ) -> Result, Box>; 52 | } 53 | 54 | pub trait RegistryReader { 55 | fn list_urls( 56 | &self, 57 | filter: Option<&dyn Filter>, 58 | sort: Option, 59 | ) -> Result, Box>; 60 | 61 | fn get_url(&self, id: &str) -> Result, Box>; 62 | } 63 | 64 | pub trait Repository: RepositoryOld { 65 | fn add(&self, record: URLRecord) -> Result>; 66 | fn add_batch( 67 | &self, 68 | record: Vec, 69 | ) -> Result, Box>; 70 | fn delete_by_id(&self, id: &str) -> Result>; 71 | fn list(&self) -> Result, Box>; 72 | fn get(&self, id: &str) -> Result, Box>; 73 | fn list_groups(&self) -> Result, Box>; 74 | fn update( 75 | &self, 76 | id: &str, 77 | record: URLRecord, 78 | ) -> Result, Box>; 79 | } 80 | 81 | pub trait RepositoryOld { 82 | fn list_v_0_0_x( 83 | &self, 84 | path: &str, 85 | ) -> Result, Box>; 86 | } 87 | 88 | pub trait Importer { 89 | fn import_from_v_0_0_x(&self, path: &str) 90 | -> Result, Box>; 91 | } 92 | -------------------------------------------------------------------------------- /src/lib/registry.rs: -------------------------------------------------------------------------------- 1 | use crate::filters::{Filter, NoopFilter}; 2 | use crate::sort::{sort_urls, SortConfig}; 3 | use crate::storage::FileStorage; 4 | use crate::types::URLRecord; 5 | use crate::util::create_temp_file; 6 | use crate::{Importer, Registry, RegistryReader, Repository}; 7 | use std::error::Error; 8 | use std::path::PathBuf; 9 | 10 | // TODO: consider introducing custom errors 11 | 12 | pub const DEFAULT_GROUP: &str = "default"; 13 | 14 | pub struct URLRegistry { 15 | storage: T, 16 | default_filter: Box, 17 | } 18 | 19 | impl URLRegistry { 20 | pub fn new_file_based(file_path: String) -> URLRegistry { 21 | let storage = FileStorage::new_urls_repository(file_path); 22 | 23 | URLRegistry { 24 | storage, 25 | default_filter: Box::new(NoopFilter::default()), 26 | } 27 | } 28 | 29 | pub fn with_temp_file( 30 | suffix: &str, 31 | ) -> Result<(URLRegistry, PathBuf), Box> { 32 | let file_path = create_temp_file(suffix)?; 33 | 34 | match file_path.to_str() { 35 | Some(path) => Ok((URLRegistry::new_file_based(path.to_string()), file_path)), 36 | None => Err(From::from( 37 | "failed to initialized registry with temp file, path is None", 38 | )), 39 | } 40 | } 41 | } 42 | 43 | impl Registry for URLRegistry { 44 | fn create( 45 | &self, 46 | name: &str, 47 | url: &str, 48 | group: Option<&str>, 49 | tags: Vec, 50 | ) -> Result> { 51 | let group = group.unwrap_or(DEFAULT_GROUP); 52 | 53 | let record = URLRecord::new(url, name, group, tags); 54 | 55 | self.storage.add(record) 56 | } 57 | 58 | fn add(&self, record: URLRecord) -> Result> { 59 | self.storage.add(record) 60 | } 61 | 62 | fn delete(&self, id: &str) -> Result> { 63 | self.storage.delete_by_id(id) 64 | } 65 | 66 | fn list_groups(&self) -> Result, Box> { 67 | self.storage.list_groups() 68 | } 69 | 70 | fn tag(&self, id: &str, tag: &str) -> Result, Box> { 71 | if tag == "" { 72 | return Err(From::from("Tag cannot be an empty string")); 73 | } 74 | 75 | let record = self.storage.get(id)?; // TODO: what should be returned here 76 | 77 | record.map_or(Ok(None), |mut record| { 78 | record.tags.entry(tag.to_string()).or_insert(true); 79 | self.storage.update(id, record) 80 | }) 81 | } 82 | 83 | fn untag(&self, id: &str, tag: &str) -> Result, Box> { 84 | if tag == "" { 85 | return Err(From::from("Tag cannot be an empty string")); 86 | } 87 | let record = self.storage.get(id)?; 88 | 89 | record.map_or(Ok(None), |mut record| { 90 | record.tags.remove(tag); 91 | self.storage.update(id, record) 92 | }) 93 | } 94 | 95 | fn change_group(&self, id: &str, group: &str) -> Result, Box> { 96 | if group == "" { 97 | return Err(From::from("Group cannot be an empty string")); 98 | } 99 | 100 | let record = self.storage.get(id)?; 101 | 102 | record.map_or(Ok(None), |mut record| { 103 | record.group = group.to_string(); 104 | self.storage.update(id, record) 105 | }) 106 | } 107 | 108 | fn change_name(&self, id: &str, name: &str) -> Result, Box> { 109 | if name == "" { 110 | return Err(From::from("Name cannot be an empty string")); 111 | } 112 | 113 | let record = self.storage.get(id)?; 114 | 115 | record.map_or(Ok(None), |mut record| { 116 | record.name = name.to_string(); 117 | self.storage.update(id, record) 118 | }) 119 | } 120 | 121 | fn change_url(&self, id: &str, url: &str) -> Result, Box> { 122 | if url == "" { 123 | return Err(From::from("URL cannot be an empty string")); 124 | } 125 | 126 | let record = self.storage.get(id)?; 127 | 128 | record.map_or(Ok(None), |mut record| { 129 | record.url = url.to_string(); 130 | self.storage.update(id, record) 131 | }) 132 | } 133 | } 134 | 135 | impl RegistryReader for URLRegistry { 136 | fn list_urls( 137 | &self, 138 | filter: Option<&dyn Filter>, 139 | sort: Option, 140 | ) -> Result, Box> { 141 | let urls = self.storage.list()?; 142 | 143 | let filter = filter.unwrap_or_else(|| self.default_filter.as_ref()); 144 | 145 | let urls = urls.into_iter().filter(|url| filter.matches(url)).collect(); 146 | 147 | if let Some(sort_cfg) = sort { 148 | return Ok(sort_urls(urls, &sort_cfg)); 149 | } 150 | 151 | Ok(urls) 152 | } 153 | 154 | fn get_url(&self, id: &str) -> Result, Box> { 155 | self.storage.get(id) 156 | } 157 | } 158 | 159 | impl Importer for URLRegistry { 160 | // TODO: opts for overriding dups, opt for migrating only unique 161 | fn import_from_v_0_0_x(&self, path: &str) -> Result, Box> { 162 | let old_urls = self.storage.list_v_0_0_x(path)?; 163 | let urls: Vec = old_urls 164 | .iter() 165 | .map(|u| { 166 | let tags = u.tags.clone().into_iter().map(|(t, _)| t).collect(); 167 | URLRecord::new(&u.url, &u.name, &u.group, tags) 168 | }) 169 | .collect(); 170 | 171 | // If at least one items fails, nothing will be saved 172 | self.storage.add_batch(urls) 173 | } 174 | } 175 | 176 | #[cfg(test)] 177 | mod test { 178 | use crate::filters::Filter; 179 | use crate::filters::{GroupFilter, TagsFilter}; 180 | use crate::registry::URLRegistry; 181 | use crate::sort::{SortBy, SortConfig}; 182 | use crate::storage::FileStorage; 183 | use crate::types::URLRecord; 184 | use crate::util::create_temp_file; 185 | use crate::{Importer, Registry, RegistryReader}; 186 | use std::collections::BTreeMap; 187 | use std::fs; 188 | use std::fs::OpenOptions; 189 | use std::io::{Seek, SeekFrom, Write}; 190 | use std::path::PathBuf; 191 | 192 | struct TestUrl { 193 | name: &'static str, 194 | url: &'static str, 195 | group: Option<&'static str>, 196 | tags: Vec<&'static str>, 197 | } 198 | 199 | #[test] 200 | fn registry_test() { 201 | let (registry, file_path) = 202 | URLRegistry::::with_temp_file("registry_tests.json") 203 | .expect("Failed to initialize registry"); 204 | 205 | let test_urls: Vec = vec![ 206 | TestUrl { 207 | name: "test1", 208 | url: "https://test.com", 209 | group: None, 210 | tags: vec![], 211 | }, 212 | TestUrl { 213 | name: "test_tagged", 214 | url: "https://test2.com", 215 | group: None, 216 | tags: vec!["tagged"], 217 | }, 218 | TestUrl { 219 | name: "test_group", 220 | url: "https://test_group.com", 221 | group: Some("test"), 222 | tags: vec!["tag2"], 223 | }, 224 | ]; 225 | 226 | let all_urls: Vec<&TestUrl> = test_urls.iter().collect(); 227 | 228 | println!("Add URLs..."); 229 | for tu in &all_urls { 230 | let result = registry 231 | .create( 232 | tu.name, 233 | tu.url, 234 | tu.group.clone(), 235 | tu.tags.iter().map(|s| s.to_string()).collect(), 236 | ) 237 | .expect("Failed to add URL record"); 238 | assert_eq!(tu.name, result.name); 239 | assert_eq!(tu.url, result.url); 240 | assert!(group_match(&tu.group, &result.group)); 241 | assert!(tags_match(&tu.tags, &result.tags)) 242 | } 243 | 244 | println!("List groups..."); 245 | let groups = registry.list_groups().expect("Failed to list groups"); 246 | assert!(groups.contains(&"default".to_string())); 247 | assert!(groups.contains(&"test".to_string())); 248 | 249 | // List all URLs 250 | println!("List urls..."); 251 | let urls = registry.list_urls(None, None).expect("Failed to list urls"); 252 | assert_urls_match(&all_urls, &urls); 253 | 254 | println!("List sorted by name..."); 255 | let sort_cfg = SortConfig::new_by(SortBy::Name); 256 | let urls = registry 257 | .list_urls(None, Some(sort_cfg)) 258 | .expect("Failed to list sorted urls"); 259 | assert_eq!(urls[0].name, "test1"); 260 | assert_eq!(urls[1].name, "test_group"); 261 | assert_eq!(urls[2].name, "test_tagged"); 262 | 263 | println!("List URLs from specific group..."); 264 | let group_to_filter = "test"; 265 | let group_filter: Box = Box::new(GroupFilter::new(group_to_filter)); 266 | 267 | let urls = registry 268 | .list_urls(Some(group_filter.as_ref()), None) 269 | .expect("Failed to list urls"); 270 | assert_eq!(1, urls.len()); 271 | 272 | let filtered_test_cases: Vec<&TestUrl> = test_urls 273 | .iter() 274 | .clone() 275 | .filter(|t| { 276 | if let Some(group) = &t.group { 277 | return *group == group_to_filter; 278 | } 279 | false 280 | }) 281 | .collect(); 282 | 283 | assert_urls_match(&filtered_test_cases, &urls); 284 | 285 | println!("List tagged URLs..."); 286 | let tags_to_filter = vec!["tagged", "tag2"]; 287 | let tags_filter: Box = Box::new(TagsFilter::new(tags_to_filter.clone())); 288 | 289 | let urls = registry 290 | .list_urls(Some(tags_filter.as_ref()), None) 291 | .expect("Failed to list urls"); 292 | assert_eq!(2, urls.len()); 293 | 294 | let filtered_test_cases: Vec<&TestUrl> = vec![&test_urls[1], &test_urls[2]]; 295 | assert_urls_match(&filtered_test_cases, &urls); 296 | 297 | println!("Delete existing URL..."); 298 | let url_0_id = urls[0].id.clone(); 299 | 300 | let deleted = registry.delete(&url_0_id).expect("Failed to delete URL"); 301 | assert!(deleted); 302 | let urls = registry.list_urls(None, None).expect("Failed to list urls"); 303 | assert_eq!(2, urls.len()); 304 | 305 | println!("Not delete if URL does not exist..."); 306 | let deleted = registry.delete(&url_0_id).expect("Failed to delete URL"); 307 | assert!(!deleted); 308 | let urls = registry.list_urls(None, None).expect("Failed to list urls"); 309 | assert_eq!(2, urls.len()); 310 | 311 | let id = urls[0].id.clone(); 312 | 313 | println!("Get url by ID..."); 314 | let url_record = registry 315 | .get_url(&id) 316 | .expect("Failed to get URL") 317 | .expect("URL record is None"); 318 | assert_eq!(url_record.id, urls[0].id); 319 | 320 | println!("Tag URL..."); 321 | let url_record = registry 322 | .tag(&id, "some-awesome-tag") 323 | .expect("Failed to tag URL") 324 | .expect("URL record is None"); 325 | assert!(url_record.tags.contains_key("some-awesome-tag")); 326 | 327 | println!("Untag URL..."); 328 | let url_record = registry 329 | .untag(&id, "tagged") 330 | .expect("Failed to untag URL") 331 | .expect("URL record is None"); 332 | assert!(!url_record.tags.contains_key("tagged")); 333 | 334 | println!("Change group..."); 335 | let url_record = registry 336 | .change_group(&id, "different-group") 337 | .expect("Failed to change URL group") 338 | .expect("URL record is None"); 339 | assert_eq!(url_record.group, "different-group"); 340 | 341 | println!("Change name..."); 342 | let url_record = registry 343 | .change_name(&id, "different-name") 344 | .expect("Failed to change URL name") 345 | .expect("URL record is None"); 346 | assert_eq!(url_record.name, "different-name"); 347 | 348 | println!("Change URL..."); 349 | let url_record = registry 350 | .change_url(&id, "https://new-url") 351 | .expect("Failed to change URL") 352 | .expect("URL record is None"); 353 | assert_eq!(url_record.url, "https://new-url"); 354 | 355 | println!("Verify changes..."); 356 | let record = registry 357 | .get_url(&id) 358 | .expect("Failed to get URL") 359 | .expect("URL record is None"); 360 | assert_eq!(record.name, "different-name"); 361 | assert_eq!(record.group, "different-group"); 362 | assert_eq!(url_record.url, "https://new-url"); 363 | assert!(record.tags.contains_key("some-awesome-tag")); 364 | assert!(!record.tags.contains_key("tagged")); 365 | 366 | println!("Cleanup..."); 367 | fs::remove_file(file_path).expect("Failed to remove file"); 368 | } 369 | 370 | fn assert_urls_match(test_urls: &Vec<&TestUrl>, actual: &Vec) { 371 | for tu in test_urls { 372 | let exists = actual.iter().any(|rec| { 373 | rec.name == tu.name 374 | && rec.url == tu.url 375 | && group_match(&tu.group, &rec.group) 376 | && tags_match(&tu.tags, &rec.tags) 377 | }); 378 | assert!(exists) 379 | } 380 | } 381 | 382 | fn group_match(input: &Option<&str>, actual: &String) -> bool { 383 | if let Some(g) = input { 384 | return g == actual; 385 | } else { 386 | "default" == actual 387 | } 388 | } 389 | 390 | fn tags_match(expected: &Vec<&str>, actual: &BTreeMap) -> bool { 391 | for t in expected { 392 | let tag = actual.get(*t).expect("Tag not present"); 393 | if !tag { 394 | return false; 395 | } 396 | } 397 | 398 | true 399 | } 400 | 401 | #[test] 402 | fn import_from_v0_0_x_test() { 403 | let (registry, file_path) = 404 | URLRegistry::::with_temp_file("registry_tests2.json") 405 | .expect("Failed to initialize registry"); 406 | 407 | let expected_urls = vec![ 408 | URLRecord::new( 409 | "https://github.com/Szymongib/bookmark-cli", 410 | "Bookmark-CLI", 411 | "projects", 412 | vec!["rust", "repo"], 413 | ), 414 | URLRecord::new( 415 | "https://github.com", 416 | "GitHub.com", 417 | "websites", 418 | Vec::::new(), 419 | ), 420 | URLRecord::new( 421 | "https://youtube.com", 422 | "YouTube", 423 | "entertainment", 424 | vec!["video"], 425 | ), 426 | URLRecord::new( 427 | "https://stackoverflow.com", 428 | "Stack Overflow", 429 | "dev", 430 | vec!["help", "dev"], 431 | ), 432 | URLRecord::new( 433 | "https://reddit.com", 434 | "Reddit", 435 | "entertainment", 436 | Vec::::new(), 437 | ), 438 | ]; 439 | 440 | let old_path = setup_old_urls_file(); 441 | 442 | println!("Should import URLs..."); 443 | let imported = registry 444 | .import_from_v_0_0_x(old_path.as_os_str().to_str().expect("Failed to get path")) 445 | .expect("Failed to import bookmarks"); 446 | 447 | assert_eq!(imported.len(), 5); 448 | for i in 0..imported.len() { 449 | assert_eq!(imported[i].id.len(), 16); 450 | assert_eq!(imported[i].name, expected_urls[i].name); 451 | assert_eq!(imported[i].url, expected_urls[i].url); 452 | assert_eq!(imported[i].group, expected_urls[i].group); 453 | assert_eq!(imported[i].tags, expected_urls[i].tags); 454 | } 455 | 456 | println!("Should fail if URLs not unique..."); 457 | let imported = registry 458 | .import_from_v_0_0_x(old_path.as_os_str().to_str().expect("Failed to get path")); 459 | assert!(imported.is_err()); 460 | 461 | println!("Cleanup..."); 462 | fs::remove_file(file_path).expect("Failed to remove file"); 463 | fs::remove_file(old_path).expect("Failed to remove file"); 464 | } 465 | 466 | fn setup_old_urls_file() -> PathBuf { 467 | let old_file_content = OLD_BOOKMARKS_FILE_CONTENT; 468 | let path = 469 | create_temp_file("registry_tests_old_file.json").expect("Failed to create temp file"); 470 | 471 | let mut file = OpenOptions::new() 472 | .read(true) 473 | .create(true) 474 | .append(false) 475 | .write(true) 476 | .open(path.clone()) 477 | .expect("Failed to open old URLs file"); 478 | 479 | file.seek(SeekFrom::Start(0)) 480 | .expect("Failed to seek to file start"); 481 | file.write_all(old_file_content.as_bytes()) 482 | .expect("Failed to write od URLs"); 483 | 484 | return path; 485 | } 486 | 487 | const OLD_BOOKMARKS_FILE_CONTENT: &str = r###" 488 | { 489 | "urls": { 490 | "items": [ 491 | { 492 | "url": "https://github.com/Szymongib/bookmark-cli", 493 | "name": "Bookmark-CLI", 494 | "group": "projects", 495 | "tags": { 496 | "rust": true, 497 | "repo": true 498 | } 499 | }, 500 | { 501 | "url": "https://github.com", 502 | "name": "GitHub.com", 503 | "group": "websites", 504 | "tags": {} 505 | }, 506 | { 507 | "url": "https://youtube.com", 508 | "name": "YouTube", 509 | "group": "entertainment", 510 | "tags": { 511 | "video": true 512 | } 513 | }, 514 | { 515 | "url": "https://stackoverflow.com", 516 | "name": "Stack Overflow", 517 | "group": "dev", 518 | "tags": { 519 | "help": true, 520 | "dev": true 521 | } 522 | }, 523 | { 524 | "url": "https://reddit.com", 525 | "name": "Reddit", 526 | "group": "entertainment", 527 | "tags": {} 528 | } 529 | ] 530 | } 531 | } 532 | "###; 533 | } 534 | -------------------------------------------------------------------------------- /src/lib/sort.rs: -------------------------------------------------------------------------------- 1 | use crate::types::URLRecord; 2 | use std::cmp::Ordering; 3 | use std::str::FromStr; 4 | 5 | // TODO: Consider adding some option to sort without applying `to_lowercase`? 6 | #[derive(Copy, Clone)] 7 | pub struct SortConfig { 8 | sort_by: SortBy, 9 | order: SortOrder, 10 | } 11 | 12 | impl SortConfig { 13 | pub fn new(sort_by: SortBy, order: SortOrder) -> SortConfig { 14 | SortConfig { sort_by, order } 15 | } 16 | 17 | pub fn new_by(sort_by: SortBy) -> SortConfig { 18 | SortConfig { 19 | sort_by, 20 | order: SortOrder::Ascending, 21 | } 22 | } 23 | } 24 | 25 | #[derive(Copy, Clone)] 26 | pub enum SortBy { 27 | Name, 28 | URL, 29 | Group, 30 | } 31 | 32 | impl FromStr for SortBy { 33 | type Err = Box; 34 | 35 | fn from_str(value: &str) -> Result { 36 | match value.to_lowercase().as_str() { 37 | "name" => Ok(SortBy::Name), 38 | "url" => Ok(SortBy::URL), 39 | "group" => Ok(SortBy::Group), 40 | _ => Err(From::from( 41 | "invalid sort column, must be one of: [name, url, group]", 42 | )), 43 | } 44 | } 45 | } 46 | 47 | #[derive(Copy, Clone)] 48 | pub enum SortOrder { 49 | Ascending, 50 | Descending, 51 | } 52 | 53 | pub(crate) fn sort_urls(mut urls: Vec, config: &SortConfig) -> Vec { 54 | let cmp_func = match config.sort_by { 55 | SortBy::Name => sort_by_name, 56 | SortBy::URL => sort_by_url, 57 | SortBy::Group => sort_by_group, 58 | }; 59 | 60 | urls.sort_by(cmp_func); 61 | 62 | match config.order { 63 | SortOrder::Ascending => {} 64 | SortOrder::Descending => urls.reverse(), 65 | }; 66 | 67 | urls 68 | } 69 | 70 | fn sort_by_name(a: &URLRecord, b: &URLRecord) -> Ordering { 71 | a.name.to_lowercase().cmp(&b.name.to_lowercase()) 72 | } 73 | 74 | fn sort_by_group(a: &URLRecord, b: &URLRecord) -> Ordering { 75 | a.group.to_lowercase().cmp(&b.group.to_lowercase()) 76 | } 77 | 78 | fn sort_by_url(a: &URLRecord, b: &URLRecord) -> Ordering { 79 | strip_protocol(&a.url.to_lowercase()).cmp(&strip_protocol(&b.url.to_lowercase())) 80 | } 81 | 82 | fn strip_protocol(url: &str) -> String { 83 | let possible_prefix = &["https://www.", "http://www.", "https://", "http://"]; 84 | for prefix in possible_prefix { 85 | if let Some(u) = url.strip_prefix(prefix) { 86 | return u.to_string(); 87 | } 88 | } 89 | 90 | url.to_string() 91 | } 92 | 93 | #[cfg(test)] 94 | mod test { 95 | use crate::sort::{sort_urls, SortBy, SortConfig, SortOrder}; 96 | use crate::types::URLRecord; 97 | 98 | fn fix_url_records() -> Vec { 99 | vec![ 100 | URLRecord::new("http://abcd", "one", "one", vec!["tag", "with space"]), 101 | URLRecord::new("https://aaaa", "Two", "two", Vec::::new()), 102 | URLRecord::new("http://www.ccc", "three", "one", Vec::::new()), 103 | URLRecord::new("https://www.cbbc", "FOUR", "abcd", vec!["tag"]), 104 | URLRecord::new("baobab", "five", "GROUP", Vec::::new()), 105 | URLRecord::new("http://xyz", "six", "one", Vec::::new()), 106 | URLRecord::new( 107 | "http://YellowSubmarine", 108 | "seven", 109 | "GROUP", 110 | Vec::::new(), 111 | ), 112 | ] 113 | } 114 | 115 | #[test] 116 | fn test_sort_by_url() { 117 | let mut records = fix_url_records(); 118 | 119 | let sort_cfg = SortConfig::new(SortBy::URL, SortOrder::Ascending); 120 | records = sort_urls(records, &sort_cfg); 121 | 122 | let expected_order = &mut [ 123 | "https://aaaa", 124 | "http://abcd", 125 | "baobab", 126 | "https://www.cbbc", 127 | "http://www.ccc", 128 | "http://xyz", 129 | "http://YellowSubmarine", 130 | ]; 131 | 132 | for (i, r) in records.iter().enumerate() { 133 | assert_eq!(r.url, expected_order[i]) 134 | } 135 | 136 | // Descending 137 | let sort_cfg = SortConfig::new(SortBy::URL, SortOrder::Descending); 138 | records = sort_urls(records, &sort_cfg); 139 | 140 | expected_order.reverse(); 141 | for (i, r) in records.iter().enumerate() { 142 | assert_eq!(r.url, expected_order[i]) 143 | } 144 | } 145 | 146 | #[test] 147 | fn test_sort_by_name() { 148 | let mut records = fix_url_records(); 149 | 150 | let sort_cfg = SortConfig::new(SortBy::Name, SortOrder::Ascending); 151 | records = sort_urls(records, &sort_cfg); 152 | 153 | let expected_order = &mut ["five", "FOUR", "one", "seven", "six", "three", "Two"]; 154 | 155 | for (i, r) in records.iter().enumerate() { 156 | assert_eq!(r.name, expected_order[i]) 157 | } 158 | 159 | // Descending 160 | let sort_cfg = SortConfig::new(SortBy::Name, SortOrder::Descending); 161 | records = sort_urls(records, &sort_cfg); 162 | 163 | expected_order.reverse(); 164 | for (i, r) in records.iter().enumerate() { 165 | assert_eq!(r.name, expected_order[i]) 166 | } 167 | } 168 | 169 | #[test] 170 | fn test_sort_by_group() { 171 | let mut records = fix_url_records(); 172 | 173 | let sort_cfg = SortConfig::new(SortBy::Group, SortOrder::Ascending); 174 | records = sort_urls(records, &sort_cfg); 175 | 176 | let expected_order = &mut ["abcd", "GROUP", "GROUP", "one", "one", "one", "two"]; 177 | 178 | for (i, r) in records.iter().enumerate() { 179 | assert_eq!(r.group, expected_order[i]) 180 | } 181 | 182 | // Descending 183 | let sort_cfg = SortConfig::new(SortBy::Group, SortOrder::Descending); 184 | records = sort_urls(records, &sort_cfg); 185 | 186 | expected_order.reverse(); 187 | for (i, r) in records.iter().enumerate() { 188 | assert_eq!(r.group, expected_order[i]) 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/lib/storage.rs: -------------------------------------------------------------------------------- 1 | use super::types::{URLRecord, URLRegistry}; 2 | use crate::import::v0_0_x; 3 | use crate::types::URLs; 4 | use crate::{Repository, RepositoryOld}; 5 | use std::collections::HashMap; 6 | use std::convert::TryInto; 7 | use std::error::Error; 8 | use std::fs; 9 | use std::fs::{File, OpenOptions}; 10 | use std::io::{Read, Seek, SeekFrom, Write}; 11 | use std::path::Path; 12 | 13 | pub struct FileStorage { 14 | file_path: String, 15 | } 16 | 17 | impl FileStorage { 18 | pub fn new_urls_repository(file_path: String) -> FileStorage { 19 | FileStorage { file_path } 20 | } 21 | 22 | fn delete_url(&self, match_first: F) -> Result> 23 | where 24 | F: Fn(&URLRecord) -> bool, 25 | { 26 | let mut file = open_urls_file(self.file_path.as_str())?; 27 | let mut registry = read_urls(&mut file)?; 28 | 29 | for (index, u) in registry.urls.items.iter().enumerate() { 30 | if match_first(u) { 31 | registry.urls.items.remove(index); 32 | write_urls(&mut file, registry)?; 33 | return Ok(true); 34 | } 35 | } 36 | 37 | Ok(false) 38 | } 39 | } 40 | 41 | impl Repository for FileStorage { 42 | fn add(&self, record: URLRecord) -> Result> { 43 | let mut file = open_urls_file(self.file_path.as_str())?; 44 | let mut registry = read_urls(&mut file)?; 45 | 46 | if !is_unique(®istry.urls.items, &record) { 47 | return Err(not_unique_error(&record)); 48 | } 49 | 50 | registry.urls.items.push(record.clone()); 51 | 52 | write_urls(&mut file, registry)?; 53 | 54 | Ok(record) 55 | } 56 | 57 | /// Adds all records to the registry as long as all of them are unique 58 | /// If at least one name-group pair is not unique, none of the URLs is saved 59 | fn add_batch(&self, records: Vec) -> Result, Box> { 60 | let mut file = open_urls_file(self.file_path.as_str())?; 61 | let mut registry = read_urls(&mut file)?; 62 | 63 | for r in &records { 64 | if !is_unique(®istry.urls.items, &r) { 65 | return Err(not_unique_error(&r)); 66 | } 67 | registry.urls.items.push(r.clone()); 68 | } 69 | 70 | let registry = write_urls(&mut file, registry)?; 71 | 72 | Ok(registry.urls.items) 73 | } 74 | 75 | fn delete_by_id(&self, id: &str) -> Result> { 76 | self.delete_url(|u| u.id == id) 77 | } 78 | 79 | fn list(&self) -> Result, Box> { 80 | let mut file = open_urls_file(self.file_path.as_str())?; 81 | let registry = read_urls(&mut file)?; 82 | Ok(registry.urls.items) 83 | } 84 | 85 | fn get(&self, id: &str) -> Result, Box> { 86 | let mut file = open_urls_file(self.file_path.as_str())?; 87 | let registry = read_urls(&mut file)?; 88 | 89 | for url in ®istry.urls.items { 90 | if url.id == id { 91 | return Ok(Some(url.clone())); 92 | } 93 | } 94 | 95 | Ok(None) 96 | } 97 | 98 | fn list_groups(&self) -> Result, Box> { 99 | let mut file = open_urls_file(self.file_path.as_str())?; 100 | let registry = read_urls(&mut file)?; 101 | 102 | let groups: Vec<&str> = registry 103 | .urls 104 | .items 105 | .iter() 106 | .map(|e: &URLRecord| e.group.as_str()) 107 | .collect(); 108 | 109 | let mut distinct: HashMap<&str, bool> = HashMap::new(); 110 | 111 | for g in groups { 112 | distinct.insert(g, false); 113 | } 114 | 115 | Ok(distinct.keys().map(|k| k.to_string()).collect()) 116 | } 117 | 118 | fn update(&self, id: &str, record: URLRecord) -> Result, Box> { 119 | let mut file = open_urls_file(self.file_path.as_str())?; 120 | let mut registry = read_urls(&mut file)?; 121 | 122 | let mut found = false; 123 | for i in 0..registry.urls.items.len() { 124 | if is_same(®istry.urls.items[i], &record) { 125 | return Err(not_unique_error(&record)); 126 | } 127 | 128 | if registry.urls.items[i].id.clone() == id { 129 | registry.urls.items[i] = record.clone(); 130 | found = true 131 | } 132 | } 133 | if !found { 134 | return Ok(None); 135 | } 136 | 137 | write_urls(&mut file, registry)?; 138 | 139 | Ok(Some(record)) 140 | } 141 | } 142 | 143 | impl RepositoryOld for FileStorage { 144 | fn list_v_0_0_x(&self, path: &str) -> Result, Box> { 145 | let mut file = open_urls_file(path)?; 146 | let content: String = read_file(&mut file)?; 147 | 148 | let urls: v0_0_x::URLRegistry = if content != "" { 149 | serde_json::from_str(content.as_str())? 150 | } else { 151 | v0_0_x::URLRegistry { 152 | urls: v0_0_x::URLs { items: vec![] }, 153 | } 154 | }; 155 | 156 | Ok(urls.urls.items) 157 | } 158 | } 159 | 160 | fn is_unique(urls: &[URLRecord], record: &URLRecord) -> bool { 161 | for u in urls { 162 | if is_same(u, record) { 163 | return false; 164 | } 165 | } 166 | 167 | true 168 | } 169 | 170 | fn is_same(a: &URLRecord, b: &URLRecord) -> bool { 171 | a.name == b.name && a.group == b.group && a.id != b.id 172 | } 173 | 174 | fn open_urls_file(path: &str) -> Result> { 175 | let path = Path::new(path); 176 | 177 | if !path.exists() { 178 | if let Some(dir_path) = path.parent() { 179 | if !dir_path.exists() { 180 | fs::create_dir_all(dir_path)?; 181 | } 182 | }; 183 | } 184 | 185 | match OpenOptions::new() 186 | .read(true) 187 | .create(true) 188 | .append(false) 189 | .write(true) 190 | .open(path) 191 | { 192 | Err(why) => Err(From::from(format!( 193 | "could not read URLs, failed to open file: {}", 194 | why 195 | ))), 196 | Ok(file) => Ok(file), 197 | } 198 | } 199 | 200 | fn read_file(file: &mut File) -> Result> { 201 | let mut content: String = String::new(); 202 | 203 | match file.read_to_string(&mut content) { 204 | Err(why) => Err(From::from(why)), 205 | _ => Ok(content), 206 | } 207 | } 208 | 209 | fn read_urls(file: &mut File) -> Result> { 210 | let content: String = read_file(file)?; 211 | 212 | let urls: URLRegistry = if content != "" { 213 | serde_json::from_str(content.as_str())? 214 | } else { 215 | URLRegistry { 216 | urls: URLs { items: vec![] }, 217 | } 218 | }; 219 | 220 | Ok(urls) 221 | } 222 | 223 | fn write_urls( 224 | file: &mut File, 225 | urls: URLRegistry, 226 | ) -> Result> { 227 | let urls_json = serde_json::to_string(&urls)?; 228 | 229 | file.seek(SeekFrom::Start(0))?; 230 | file.write_all(urls_json.as_bytes())?; 231 | 232 | let desired_length: u64 = urls_json.len().try_into()?; 233 | file.set_len(desired_length)?; 234 | 235 | Ok(urls) 236 | } 237 | 238 | fn not_unique_error(record: &URLRecord) -> Box { 239 | From::from(format!( 240 | "URL with name '{}' already exists in '{}' group", 241 | record.name.clone(), 242 | record.group.clone() 243 | )) 244 | } 245 | -------------------------------------------------------------------------------- /src/lib/types.rs: -------------------------------------------------------------------------------- 1 | use rand::Rng; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::BTreeMap; 4 | use std::fmt; 5 | 6 | #[derive(Serialize, Deserialize)] 7 | pub struct URLRegistry { 8 | pub urls: URLs, 9 | } 10 | 11 | #[derive(Serialize, Deserialize, Debug)] 12 | pub struct URLGroups { 13 | pub items: Vec, 14 | } 15 | 16 | #[derive(Serialize, Deserialize, Debug)] 17 | pub struct URLGroup { 18 | pub name: String, 19 | } 20 | 21 | impl URLGroup { 22 | pub fn new(name: String) -> URLGroup { 23 | URLGroup { name } 24 | } 25 | } 26 | 27 | #[derive(Serialize, Deserialize)] 28 | pub struct URLs { 29 | pub items: Vec, 30 | } 31 | 32 | #[derive(Serialize, Deserialize, Clone, Debug)] 33 | pub struct URLRecord { 34 | pub id: String, 35 | pub url: String, 36 | pub name: String, 37 | pub group: String, 38 | pub tags: BTreeMap, 39 | } 40 | 41 | impl URLRecord { 42 | pub fn new>(url: &str, name: &str, group: &str, tags_vec: Vec) -> URLRecord { 43 | let mut tags: BTreeMap = BTreeMap::new(); 44 | for t in tags_vec { 45 | tags.insert(t.into(), true); 46 | } 47 | 48 | let random_bytes = rand::thread_rng().gen::<[u8; 8]>(); 49 | 50 | URLRecord { 51 | id: hex::encode(random_bytes), 52 | url: url.to_string(), 53 | name: name.to_string(), 54 | group: group.to_string(), 55 | tags, 56 | } 57 | } 58 | 59 | pub fn tags_as_string(&self) -> String { 60 | let tags: Vec = self 61 | .tags 62 | .keys() 63 | .map(|k| { 64 | if k.contains(|c| c == ' ' || c == ',') { 65 | format!("\"{}\"", k) 66 | } else { 67 | k.to_owned() 68 | } 69 | }) 70 | .collect(); 71 | tags.join(", ") 72 | } 73 | } 74 | 75 | impl fmt::Display for URLRecord { 76 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 77 | write!( 78 | f, 79 | "Name: {}, URL: {}, Group: {}, Tags: {:?}", 80 | self.name, self.url, self.group, self.tags 81 | ) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/lib/util.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fs::File; 3 | use std::path::PathBuf; 4 | use std::time::SystemTime; 5 | 6 | pub(crate) fn create_temp_file(suffix: &str) -> Result> { 7 | let time = SystemTime::now().elapsed()?.as_nanos(); 8 | 9 | let mut temp_path = env::temp_dir(); 10 | temp_path.push(format!("{}_{}", time, suffix)); 11 | 12 | File::create(temp_path.clone())?; 13 | 14 | Ok(temp_path) 15 | } 16 | --------------------------------------------------------------------------------