├── .all-contributorsrc ├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── build.rs ├── demos ├── DEMOS.md ├── define.gif ├── direct_demo.gif ├── file_demo.gif ├── history_demo.gif ├── main_demo.gif ├── stack-overflow.gif ├── stocks.gif └── what.gif ├── dist-workspace.toml ├── flake.nix ├── scripts ├── is-fast-projects.ps1 └── is-fast-projects.sh ├── src ├── actions │ ├── generate_config.rs │ ├── mod.rs │ └── prepare_pages.rs ├── app │ ├── enum_values.rs │ ├── event_loop.rs │ ├── history.rs │ ├── mod.rs │ ├── page.rs │ ├── text.rs │ └── tui.rs ├── cli │ ├── command.rs │ ├── mod.rs │ └── parser.rs ├── config │ ├── alternate_headers.toml │ ├── color_conversion.rs │ ├── config.toml │ ├── files.rs │ ├── format.rs │ ├── glob_generation.rs │ ├── load.rs │ ├── log.rs │ ├── mod.rs │ ├── site.rs │ ├── site_raw.rs │ └── tool_raw.rs ├── database │ ├── history_database.rs │ └── mod.rs ├── errors │ ├── error.rs │ └── mod.rs ├── main.rs ├── page │ ├── mod.rs │ └── structure.rs ├── pipe │ ├── history.rs │ └── mod.rs ├── search_engine │ ├── cache.rs │ ├── duckduckgo.rs │ ├── google.rs │ ├── kagi.rs │ ├── link.rs │ ├── mod.rs │ ├── scrape.rs │ ├── search.rs │ └── search_type.rs ├── transform │ ├── cache.rs │ ├── filter.rs │ ├── format.rs │ ├── mod.rs │ ├── page.rs │ ├── pretty_print.rs │ └── syntax_highlight.rs └── tui │ ├── display.rs │ ├── general_widgets.rs │ ├── history_content.rs │ ├── history_widgets.rs │ ├── mod.rs │ ├── page_content.rs │ └── page_widgets.rs ├── tests └── data │ ├── expected_ansi_text.txt │ ├── expected_nth.txt │ ├── expected_selected.txt │ ├── expected_text.txt │ ├── invalid_lang_config.toml │ ├── sample.html │ ├── selector_override_config.toml │ ├── test_output_margin_wrap.txt │ ├── test_output_title.txt │ ├── test_output_title_margin_wrap.txt │ ├── unformatted.txt │ └── unformatted_result.txt └── wix └── main.wxs /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "is-fast", 3 | "projectOwner": "Magic-JD", 4 | "files": [ 5 | "README.md" 6 | ], 7 | "commitType": "docs", 8 | "commitConvention": "angular", 9 | "contributorsPerLine": 7, 10 | "contributors": [ 11 | { 12 | "login": "pwnwriter", 13 | "name": "Nabeen Tiwaree", 14 | "avatar_url": "https://avatars.githubusercontent.com/u/90331517?v=4", 15 | "profile": "http://pwnwriter.me", 16 | "contributions": [ 17 | "platform" 18 | ] 19 | }, 20 | { 21 | "login": "rehanzo", 22 | "name": "Rehan", 23 | "avatar_url": "https://avatars.githubusercontent.com/u/60243794?v=4", 24 | "profile": "https://github.com/rehanzo", 25 | "contributions": [ 26 | "plugin" 27 | ] 28 | }, 29 | { 30 | "login": "d3-X-t3r", 31 | "name": "d3Xt3r", 32 | "avatar_url": "https://avatars.githubusercontent.com/u/1624052?v=4", 33 | "profile": "https://github.com/d3-X-t3r", 34 | "contributions": [ 35 | "ideas" 36 | ] 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | tests/data/sample.html linguist-generated -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [magic-jd] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: is-fast CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | rustfmt: 11 | name: Check Formatting 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Rust 18 | uses: dtolnay/rust-toolchain@stable 19 | with: 20 | toolchain: stable 21 | components: rustfmt 22 | 23 | - name: Run rustfmt 24 | run: cargo fmt --check 25 | 26 | clippy: 27 | name: Lint with Clippy 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v4 32 | 33 | - name: Set up Rust 34 | uses: dtolnay/rust-toolchain@stable 35 | with: 36 | toolchain: stable 37 | components: clippy 38 | 39 | - name: Cache dependencies 40 | uses: Swatinem/rust-cache@v2 41 | 42 | - name: Run Clippy 43 | run: cargo clippy --all-targets -- -D warnings > clippy-report.json 44 | 45 | - name: Upload Clippy Report 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: clippy-report 49 | path: clippy-report.json 50 | 51 | test: 52 | name: Run Tests 53 | runs-on: ubuntu-latest 54 | steps: 55 | - name: Checkout repository 56 | uses: actions/checkout@v4 57 | 58 | - name: Set up Rust 59 | uses: dtolnay/rust-toolchain@stable 60 | 61 | - name: Cache dependencies 62 | uses: Swatinem/rust-cache@v2 63 | 64 | - name: Install cargo-tarpaulin 65 | run: cargo install cargo-tarpaulin 66 | 67 | - name: Run tests 68 | run: cargo tarpaulin --out Xml --output-dir target/llvm-cov 69 | 70 | - name: Upload Coverage Report 71 | uses: actions/upload-artifact@v4 72 | with: 73 | name: coverage-report 74 | path: target/llvm-cov/cobertura.xml 75 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run nix build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'src/*' 9 | - 'flake.*' 10 | - 'Cargo.*' 11 | 12 | jobs: 13 | run-tests: 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, macos-latest] 17 | runs-on: ${{ matrix.os }} 18 | 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v2 22 | 23 | - name: Set up Nix 24 | uses: DeterminateSystems/nix-installer-action@main 25 | 26 | - name: Nix test 27 | run: nix develop -c cargo test -- --skip=generate_config::tests::test_run_creates_config_file 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.16.2] 4 | ### Fix 5 | - Let site selection in config be applied. (currently only the flag is working correctly) 6 | 7 | ### Added 8 | - Configuration for timeout. 9 | 10 | ## [0.16.1] 11 | ### Fix 12 | - Change reqwest to ureq due to issues with cloudflare blocking reqwest. 13 | 14 | ## [0.16.0] 15 | ### Added 16 | - Log feature flag for allowing logging to be easily set. 17 | - Documentation for new features, and explaining how to get more detailed logs. 18 | - Upgraded the logging, logging more actions. 19 | 20 | ## [0.15.14] 21 | ### Fix 22 | - Allow arguments to be passed into the open tool rather than just including a tool name. 23 | - Update the release files so they use correct ubuntu version 24 | 25 | ## [0.15.3] 26 | ### Fix 27 | - Fixed bug with cursor not appearing after tui shutdown 28 | 29 | ## [0.15.1] 30 | ### Added 31 | - Added support for kitty text size protocol. 32 | - For supported terminals (kitty v0.40.0+, not using tmux, zellig, screen, ect) text size can be set in the configuration. 33 | - This will only show when the text is output to the terminal directly using piped. 34 | - This must be enabled in the configuration in the misc section. 35 | 36 | ## [0.14.1] 37 | ### Fix 38 | - Added brotli decoding to handle some sites that use it. 39 | 40 | ## [0.14.0] 41 | ### Added 42 | - Styles also works with basic CSS syntax. 43 | - Styles will combine together, with priority being basic, tag, untagged class, tagged class, untagged id, tagged id. 44 | - e.g. The element is div#this.that 45 | - Default style is undefined 46 | - .that (matches any .that) is BOLD and RED 47 | - \#this is ITALIC and BLUE 48 | - div is UNDERLINED 49 | - div.that is GREEN 50 | - div#this is ORANGE 51 | - The result will be BOLD, ITALIC, UNDERLINED, and ORANGE (with the color priority order being ORANGE, BLUE, GREEN, RED). 52 | - Styles can be passed into the query directly. 53 | - Format is `--style-element="tag#id.class.otherclass:fg=red;bg=green;bold"` 54 | - For booleans `bold` is equal to `bold=true` 55 | - Multiple style elements can be added with multiple flags: `--style-element="x:fg=red" --style-element="y:fg=blue"` 56 | - All the normal style elements are supported. 57 | - Scripts updated to add color to the stock script. 58 | 59 | ## [0.13.3] 60 | ### Added 61 | - You can now specify if you want to replace or extend the block, indent and ignored elements. 62 | 63 | ## [0.13.2] 64 | ### Fixed 65 | - Longest and therefore most specific glob match will always be returned. 66 | 67 | ## [0.13.1] 68 | ### Added 69 | - Indent elements, allowing you to indent nested elements. Currently only set for lists. 70 | - To allow nested lists 71 | - Like this 72 | - To display 73 | - Correctly 74 | - This is set in the site specific configuration, and I hope that this can be used with custom selectors to help comment chains ect to display better in the future. 75 | 76 | ### Fixed 77 | - Ordered the http headers when requesting a page, as this can effect if the request is blocked or not. 78 | 79 | ## [0.13.0] 80 | ### Added 81 | - Site specific configuration. Configuration that should only apply to one site, can now be matched only to that site. 82 | - Site configs are added with `[custom_config]` `"*site.glob.match/*" = ["alternate_headers.toml", "interesting_colorscheme.toml"]` 83 | - All configs supplied will be added to the base config. 84 | - The custom config files should be placed in the same location as your standard config. 85 | - A small number of custom files will be inbuilt into the binary (currently just alternate_headers). A local file with the same name will take priority over them. 86 | - Headers has been added as a new configuration. These can be configured generally or on a site by site basis. 87 | 88 | ### Fixed 89 | - The stock mini script works again. 90 | 91 | ## [0.12.4] 92 | ### Added 93 | - Additional selectors for old.reddit.com and better selectors for stack overflow. 94 | 95 | ### Fixed 96 | - Too high indentation, reduced it slightly. 97 | 98 | ## [0.12.3] 99 | ### Added 100 | - Ability to configure the log, database and config location through environment variables. 101 | 102 | ## [0.12.2] 103 | ### Fixed 104 | - Buggy list icons when part of an ordered list. 105 | 106 | ### Added 107 | - Numbers for ordered lists. 108 | - Indentation for nested lists. 109 | 110 | ## [0.12.1] 111 | ### Fixed 112 | - Unicode is now correctly rendered for all sites. 113 | - Preloading no longer starts a new thread if one is not needed. 114 | 115 | ### Changed 116 | - Removed a number of dependencies, where two dependencies were used that performed the same function. 117 | 118 | ## [0.12.0] 119 | ### Added 120 | - New flag for adding additional ignore tags. `--ignore="div"` 121 | - New flag for not applying block elements (reducing non formatted or `
` code to a single line). 122 | - All tags for ignored and blocked elements support basic css selector features (.class or #id) 123 | - Title `--pretty-print` value will now default to the page title if no title is provided. 124 | - Support multiple elements with one flag for `--nth-element` 125 | - A number of new default page selectors. 126 | - New script for doing quick conversion checks. 127 | 128 | ### Fixed 129 | - Google search page is now supported to view, as are a number of other pages that were previously blocked. 130 | - Spaces in direct urls are now converted to + for ease of scripting use. 131 | 132 | ### Changed 133 | - Refactor of code across many files, splitting up logic especially in the Config struct/s. 134 | - Updated a number of dependencies in the cargo lock. 135 | 136 | ## [0.11.4] 137 | ### Changed 138 | - Increased the level of details in the logs. 139 | 140 | ### Fixed 141 | - Allow the title to be updated in the history. 142 | - Output shown and exit with error code when no results are found. 143 | 144 | ## [0.11.3] 145 | ### Fixed 146 | - Increased the timeout due to the previous timeout being too short. 147 | 148 | ## [0.11.2] 149 | ### Added 150 | - Logging to file when the `RUST_LOG` env var is enabled. 151 | - The log file will only be created if that environment variable is enabled. 152 | - Logs will be placed in the is-fast config directory. 153 | - When enabled, there will be an output to stderr to show the log location. 154 | - Explicit flag for cache level (readwrite, read, write, none, flash). 155 | - Better error messages when the page fails to load. 156 | 157 | ### Changed 158 | - Page title is now extracted from HTML, meaning that there is no need to retrieve from the search results. 159 | 160 | ### Fixed 161 | - History tracking now works with everything except the `--file` flag. 162 | - Parallel loading of the next page is much more efficient. 163 | - Errors or non-content now lead to that url being purged from the cache, preventing a bad cache causing lasting issues. 164 | 165 | ## [0.11.1] 166 | ### Changed 167 | - Error when updating 168 | 169 | ## [0.11.0] 170 | ### Added 171 | - Additional configuration for printed output using the --pretty-print command. 172 | - Format is command:value,command:value 173 | - Can wrap the output. - Command = wrap, no value needed, use --pretty-print="wrap" 174 | - Can apply a margin to the output. - Command = margin, value = number, use --pretty-print="margin:10" 175 | - NOTE: Margin this will automatically wrap. I think this should be the desired behaviour anyway if you want margins. 176 | - Can apply a title to the output. - Command = title, value = string, use --pretty-print="title:TITLE" 177 | - NOTE: The title cannot contain the characters , or : due to parsing issues. 178 | 179 | ### Changed 180 | - Updated documentation to include these configuration changes. 181 | - Updated the example scripts to take advantage of these new features. 182 | 183 | ### Fixed 184 | - Bug where ad results would sometimes be retrieved from duckduckgo 185 | 186 | ## [0.10.1] 187 | ### Added 188 | - The ability to cache your results. Although this option is off by default, enabling it speeds up the time mostly static pages take to reload if you close and open them again. 189 | - Added configuration for this 190 | - TTL 191 | - Max cache size 192 | - Setting for it (Disabled, Read, Write, ReadWrite) 193 | - Added command flags for this: 194 | - `--cache` will cache the result even if caching is normally disabled. 195 | - `--no-cache` will not cache the result even if caching is normally enabled. 196 | - `--flash-cache` uses a special mode where the cache size is maximum for the duration of the request, but the TTL is only 5 seconds. This has some application for scripting, where you don't want to fill your cache, but you want results to persist there throughout the duration of your script. 197 | - `--clear-cache` removes all items from the cache. 198 | - Added additional command flags for history too. 199 | - `--clear-history` clears the history. 200 | - `--clear-all` clears both cache and history. 201 | - `--no-history` will not log history for that request. 202 | 203 | ## [0.10.0] 204 | ### Fixed 205 | - Accidentally skipped updating in 0.10.1 206 | 207 | ### Changed 208 | - Enabled command flags to be able to be passed into the config, centralizing the configuration logic. 209 | - Split config file into the raw and processed config. 210 | 211 | ### Fixed 212 | - Clippy pedantic issues. 213 | - Bug where input is buffered while waiting for the loading screen causing unexpected behaviour on page load. 214 | 215 | ## [0.9.0] 216 | ### Added 217 | - A number of exciting features for scripting with `is-fast` 218 | - Scripts Directory containing a number of example scripts for how you could use `is-fast` for useful programs. 219 | - New flag `--last` - will immediately open the last viewed page (requires history to be enabled) 220 | - New flag `--nth-element` - will select the nth element that matches the css selector in the case there are multiple matches. 221 | - Contributers section to give the kind people that contribute to this project the appreciation they deserve ❤️ 222 | - Generation of flag autocomplete scripts as part of the build process. 223 | 224 | ### Changed 225 | - Moved the selection logic from the link to the page. 226 | - Page now responsible for user passed flags. 227 | - Simplifies the link creation in the search engine. 228 | 229 | ## [0.8.5] - 2025-03-11 230 | ### Added 231 | - Support for 32 bit linux releases 232 | 233 | ## [0.8.4] - 2025-03-11 234 | ### Fixed 235 | - Automated changelog and release notes 🤞 236 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "is-fast" 3 | version = "0.16.2" 4 | edition = "2021" 5 | repository = "https://github.com/Magic-JD/is-fast" 6 | homepage = "https://github.com/Magic-JD/is-fast" 7 | description = "Internet search fast - view webpages from the terminal." 8 | authors = ["Joseph Daunt"] 9 | 10 | [package.metadata.wix] 11 | upgrade-guid = "98558109-78F3-44AC-94BB-270EA47A5129" 12 | path-guid = "1E9BD8F8-FFD2-4F99-B266-C805B17FEAB9" 13 | license = false 14 | eula = false 15 | 16 | [dependencies] 17 | crossterm = "0.28.1" 18 | ratatui = "0.29.0" 19 | ureq = "2.9" 20 | scraper = "0.23.1" 21 | thiserror = "2.0.11" 22 | once_cell = "1.20.3" 23 | syntect = "5.2.0" 24 | serde = { version = "1.0.218", features = ["derive"] } 25 | toml = "0.8.20" 26 | dirs = "6.0.0" 27 | clap = { version = "4.5.31", features = ["derive"] } 28 | open = "5.3.2" 29 | dashmap = "7.0.0-rc1" 30 | globset = "0.4.16" 31 | rusqlite = { version = "0.34.0", features = ["bundled"] } 32 | chrono = "0.4.40" 33 | nucleo-matcher = "0.3.1" 34 | serde_json = "1.0.139" 35 | csv = "1.3.1" 36 | enum_dispatch = "0.3.13" 37 | nu-ansi-term = "0.50.1" 38 | log = "0.4.26" 39 | env_logger = "0.11.7" 40 | zstd = "0.13.3" 41 | parking_lot = "0.12.3" 42 | textwrap = "0.16.2" 43 | encoding_rs = "0.8.35" 44 | encoding_rs_io = "0.1.7" 45 | ctor = "0.4.1" 46 | brotli = "7.0.0" 47 | shell-words = "1.1.0" 48 | 49 | [build-dependencies] 50 | clap = { version = "4.5.31", features = ["derive", "cargo", "env"] } 51 | clap_mangen = "0.2.26" 52 | clap_complete = "4.5.46" 53 | 54 | [dev-dependencies] 55 | serial_test = "3.2.0" 56 | tempfile = "3.17.1" 57 | 58 | # The profile that 'dist' will build with 59 | [profile.dist] 60 | inherits = "release" 61 | lto = "thin" 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Joseph Daunt 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 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use clap::CommandFactory; 2 | use clap_complete::{ 3 | generate_to, 4 | shells::{Bash, Fish, PowerShell, Zsh}, 5 | }; 6 | use clap_mangen::Man; 7 | use std::fs::File; 8 | use std::io::Write; 9 | 10 | include!("src/cli/command.rs"); 11 | 12 | fn main() { 13 | println!("cargo:rerun-if-changed=build.rs"); 14 | println!("cargo:rerun-if-changed=src/cli/command.rs"); 15 | 16 | let cmd = Cli::command(); 17 | 18 | let mut buffer = Vec::new(); 19 | Man::new(cmd) 20 | .render(&mut buffer) 21 | .expect("Failed to generate man page"); 22 | 23 | let out_dir = std::env::var("OUT_DIR").unwrap(); 24 | let man_path = std::path::Path::new(&out_dir).join("is-fast.1"); 25 | 26 | let mut file = File::create(&man_path).expect("Failed to create man file"); 27 | file.write_all(&buffer).expect("Failed to write man file"); 28 | 29 | let mut cmd = Cli::command(); 30 | generate_to(Bash, &mut cmd, "is-fast", &out_dir).unwrap(); 31 | generate_to(Zsh, &mut cmd, "is-fast", &out_dir).unwrap(); 32 | generate_to(Fish, &mut cmd, "is-fast", &out_dir).unwrap(); 33 | generate_to(PowerShell, &mut cmd, "is-fast", &out_dir).unwrap(); 34 | } 35 | -------------------------------------------------------------------------------- /demos/DEMOS.md: -------------------------------------------------------------------------------- 1 | # **Demos: Exploring the Capabilities of `is-fast`** 2 | 3 | `is-fast` is designed for speed and efficiency in browsing, searching, and extracting web content. Below are demonstrations showcasing its key features. 4 | 5 | --- 6 | 7 | ## 🚀 **Fast & Efficient Browsing** 8 | Effortlessly see search results, scroll through pages, and quickly jump between results. Users can see the top results for their searches and navigate between them. The next page is always preloaded, so you shouldn't have to wait. 9 | 10 | ![Main Demo](main_demo.gif) 11 | 12 | --- 13 | 14 | ## 🔍 **Search Your History with Fuzzy Matching** 15 | Easily find and reopen pages you've visited before using powerful fuzzy search. Your history is stored in a local database so it's always available. 16 | 17 | ![History Demo](history_demo.gif) 18 | 19 | --- 20 | 21 | ## 🔗 **Direct Navigation & Content Extraction** 22 | Already know the page you're looking for? Jump straight to it. Using selectors, you can filter and extract only the relevant parts of a webpage. Piping the output allows integration with other tools. 23 | 24 | ![Direct Demo](direct_demo.gif) 25 | 26 | --- 27 | 28 | ## 📂 **Working with Local Files** 29 | No internet? No problem! Load and format saved HTML files with ease. Use predefined selectors based on the original URL, or specify custom ones for targeted extraction. Output formatted content to standard out for further processing. 30 | 31 | ![File Demo](file_demo.gif) 32 | 33 | --- 34 | # Scripting Applications 35 | 36 | These are a number of small scripts that I wrote to demonstrate the power of `is-fast` as a CLI and scripting tool. You can find these scripts in the [scripts](../scripts) folder. 37 | 38 | ## What 39 | 40 | This script returns the first paragraph of the wikipedia article about what you searched, giving you a nice overview of that person/thing. 41 | 42 | ```sh 43 | isf_what() { 44 | is-fast \ 45 | --direct "en.wikipedia.org/wiki/${*}" \ 46 | --selector "div.mw-content-ltr > p" \ 47 | --color=always \ 48 | --piped \ 49 | --nth-element 1 \ 50 | --pretty-print="margin:20" 51 | } 52 | ``` 53 | 54 | ![What Demo](what.gif) 55 | 56 | ## Stocks 57 | 58 | Check current stock prices! 59 | 60 | ```shell 61 | isf_stock() { 62 | is-fast \ 63 | --direct "https://finance.yahoo.com/quote/${1}/" \ 64 | --selector "section.container > h1, span.base" \ 65 | --piped \ 66 | --no-cache \ 67 | --pretty-print="margin:5" 68 | } 69 | ``` 70 | 71 | ![Stocks Demo](stocks.gif) 72 | 73 | ## Define 74 | 75 | This script finds the definition of the given word. 76 | 77 | ```sh 78 | # NOTE capitalization is specific for ZSH - for BASH change to ${1^} 79 | isf_define() { 80 | is-fast \ 81 | --direct "www.merriam-webster.com/dictionary/${1}" \ 82 | --selector "div.sb" \ 83 | --nth-element 1 \ 84 | --color=always \ 85 | --pretty-print="margin:20,title:${(C)1}" \ 86 | --piped 87 | } 88 | 89 | ``` 90 | ![Define Demo](define.gif) 91 | 92 | ## Search Stack Overflow 93 | 94 | This script searches Stack Overflow for a related question, and displays a well formatted question and answer directly to the terminal. 95 | 96 | ```sh 97 | isf_so() { 98 | QUESTION=$(is-fast ${*} --site "www.stackoverflow.com" --selector "div.question .js-post-body" --color=always --pretty-print="margin:20,title:Question" --piped --flash-cache) # Find the question content. 99 | ANSWER=$(is-fast --last --selector "div.accepted-answer .js-post-body" --color=always --pretty-print="margin:20,title:Answer" --piped --flash-cache) # Separately find the answer content. 100 | cat << EOF 101 | 102 | $QUESTION 103 | $ANSWER 104 | 105 | EOF 106 | } 107 | 108 | ``` 109 | 110 | ![Stack Overflow Demo](stack-overflow.gif) 111 | -------------------------------------------------------------------------------- /demos/define.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Magic-JD/is-fast/eea32cf82df4e0a575610d9d9fbafed26ec222d9/demos/define.gif -------------------------------------------------------------------------------- /demos/direct_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Magic-JD/is-fast/eea32cf82df4e0a575610d9d9fbafed26ec222d9/demos/direct_demo.gif -------------------------------------------------------------------------------- /demos/file_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Magic-JD/is-fast/eea32cf82df4e0a575610d9d9fbafed26ec222d9/demos/file_demo.gif -------------------------------------------------------------------------------- /demos/history_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Magic-JD/is-fast/eea32cf82df4e0a575610d9d9fbafed26ec222d9/demos/history_demo.gif -------------------------------------------------------------------------------- /demos/main_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Magic-JD/is-fast/eea32cf82df4e0a575610d9d9fbafed26ec222d9/demos/main_demo.gif -------------------------------------------------------------------------------- /demos/stack-overflow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Magic-JD/is-fast/eea32cf82df4e0a575610d9d9fbafed26ec222d9/demos/stack-overflow.gif -------------------------------------------------------------------------------- /demos/stocks.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Magic-JD/is-fast/eea32cf82df4e0a575610d9d9fbafed26ec222d9/demos/stocks.gif -------------------------------------------------------------------------------- /demos/what.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Magic-JD/is-fast/eea32cf82df4e0a575610d9d9fbafed26ec222d9/demos/what.gif -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | # Config for 'dist' 5 | [dist] 6 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 7 | cargo-dist-version = "0.28.0" 8 | # CI backends to support 9 | ci = "github" 10 | # The installers to generate for each app 11 | installers = ["shell", "powershell", "homebrew", "msi"] 12 | # A GitHub repo to push Homebrew formulas to 13 | tap = "magic-jd/homebrew-tap" 14 | # Target platforms to build apps for (Rust target-triple syntax) 15 | targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc", "i686-unknown-linux-gnu", "i686-unknown-linux-musl"] 16 | # Path that installers should place binaries in 17 | install-path = "CARGO_HOME" 18 | # Publish jobs to run in CI 19 | publish-jobs = ["homebrew"] 20 | # Whether to install an updater program 21 | install-updater = false 22 | 23 | [dist.github-custom-runners] 24 | global = "ubuntu-latest" 25 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "is-fast"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 6 | }; 7 | 8 | outputs = 9 | { self, nixpkgs, ... }: 10 | let 11 | forAllSystems = 12 | function: 13 | nixpkgs.lib.genAttrs [ 14 | "x86_64-linux" 15 | "aarch64-linux" 16 | "x86_64-darwin" 17 | "aarch64-darwin" 18 | ] (system: function nixpkgs.legacyPackages.${system}); 19 | 20 | darwinDeps = 21 | pkgs: with pkgs; [ 22 | darwin.apple_sdk.frameworks.SystemConfiguration 23 | libiconv 24 | ]; 25 | in 26 | { 27 | devShells = forAllSystems (pkgs: { 28 | default = pkgs.mkShell { 29 | name = "is-fast"; 30 | packages = 31 | (with pkgs; [ 32 | cargo 33 | cargo-edit 34 | clippy 35 | rustc 36 | ]) 37 | ++ (pkgs.lib.optional pkgs.stdenvNoCC.isDarwin (darwinDeps pkgs)); 38 | }; 39 | }); 40 | formatter = forAllSystems (pkgs: pkgs.nixfmt-rfc-style); 41 | packages = forAllSystems (pkgs: { 42 | is-fast = 43 | with pkgs; 44 | let 45 | fs = lib.fileset; 46 | sourceFiles = fs.unions [ 47 | ./tests 48 | ./Cargo.lock 49 | ./Cargo.toml 50 | ./src 51 | ]; 52 | 53 | cargoToml = with builtins; (fromTOML (readFile ./Cargo.toml)); 54 | pname = cargoToml.package.name; 55 | version = cargoToml.package.version; 56 | cargoLock.lockFile = ./Cargo.lock; 57 | darwinBuildInputs = (darwinDeps pkgs); 58 | in 59 | pkgs.rustPlatform.buildRustPackage { 60 | inherit pname version cargoLock; 61 | src = fs.toSource { 62 | root = ./.; 63 | fileset = sourceFiles; 64 | }; 65 | nativeBuildInputs = [ 66 | clippy 67 | rustfmt 68 | openssl 69 | ]; 70 | buildInputs = [ ] ++ lib.optionals stdenv.isDarwin darwinBuildInputs; 71 | 72 | # Skip fake home for now 73 | app_test = '' 74 | cargo fmt --manifest-path ./Cargo.toml --all --check 75 | cargo clippy -- --deny warnings 76 | cargo test -- --skip=generate_config::tests::test_run_creates_config_file 77 | ''; 78 | 79 | preBuildPhases = [ "app_test" ]; 80 | 81 | }; 82 | default = self.packages.${pkgs.system}.is-fast; 83 | }); 84 | apps = forAllSystems (pkgs: { 85 | default = { 86 | type = "app"; 87 | program = "${self.packages.${pkgs.system}.is-fast}/bin/is-fast"; 88 | }; 89 | }); 90 | }; 91 | } 92 | -------------------------------------------------------------------------------- /scripts/is-fast-projects.ps1: -------------------------------------------------------------------------------- 1 | # Check stock prices using is-fast. Args must be a stock symbol (e.g., AAPL). 2 | function isf_stock { 3 | param ( 4 | [string]$symbol 5 | ) 6 | is-fast ` 7 | --direct "https://finance.yahoo.com/quote/$symbol/" ` 8 | --selector "section.container > h1, span.base" ` 9 | --piped ` 10 | --no-cache ` 11 | --pretty-print="margin:5" 12 | } 13 | 14 | # What is something? Give it a word or a name, and it will return the first Wikipedia paragraph of that thing. 15 | function isf_what { 16 | param ( 17 | [string]$query 18 | ) 19 | is-fast ` 20 | --direct "https://en.wikipedia.org/wiki/$query" ` 21 | --selector "div.mw-content-ltr > p" ` 22 | --color=always ` 23 | --piped ` 24 | --nth-element 1 ` 25 | --pretty-print="margin:20" 26 | } 27 | 28 | # Search Stack Overflow, showing only the question and answer text. 29 | function isf_so { 30 | param ( 31 | [string]$query 32 | ) 33 | $QUESTION = is-fast $query --site "www.stackoverflow.com" --selector "div.question .js-post-body" --color=always --pretty-print="margin:20,title:Question" --piped --flash-cache 34 | $ANSWER = is-fast --last --selector "div.accepted-answer .js-post-body" --color=always --pretty-print="margin:20,title:Answer" --piped --flash-cache 35 | Write-Output @" 36 | 37 | $QUESTION 38 | $ANSWER 39 | 40 | "@ 41 | } 42 | 43 | # Get a simple definition of a word. 44 | function isf_define { 45 | param ( 46 | [string]$word 47 | ) 48 | is-fast ` 49 | --direct "https://www.merriam-webster.com/dictionary/$word" ` 50 | --selector "div.sb" ` 51 | --nth-element 1 ` 52 | --color=always ` 53 | --pretty-print="margin:20,title:$($word.ToUpper())" ` 54 | --piped 55 | } 56 | 57 | # Check the current number of stars in the repo. 58 | function isf_stars { 59 | is-fast ` 60 | --direct "https://github.com/Magic-JD/is-fast" ` 61 | --selector "span#repo-stars-counter-star" ` 62 | --pretty-print="title:Current Stars,margin:5" ` 63 | --color=always ` 64 | --piped ` 65 | --no-cache 66 | } 67 | 68 | # Checks the Google page to get the information for the info box. 69 | function isf_quick { 70 | param ( 71 | [string]$query 72 | ) 73 | is-fast ` 74 | --direct "https://www.google.com/search?q=$query" ` 75 | --piped ` 76 | --selector="div.ezO2md" ` 77 | --ignore="a" ` 78 | --no-block ` 79 | --nth-element 1 ` 80 | --pretty-print="margin:20" ` 81 | --color=always ` 82 | --no-cache 83 | } 84 | -------------------------------------------------------------------------------- /scripts/is-fast-projects.sh: -------------------------------------------------------------------------------- 1 | # A number of scripts that I created to show the flexibility of is-fast for scripting. This is not meant to be an exhaustive list of all the things that is-fast can do, 2 | # but rather just some examples of neat functions that I put together to show how you could use this tool in your workflow. 3 | 4 | # Check stock prices using is-fast. Args must be a stock symbol (e.g. AAPL). 5 | # Insert the stock symbol into the url 6 | # Select span elements with the base class 7 | # We want this output to display directly in the terminal, rather than being shown in the tui so we use --piped. 8 | # By default these spans are not colored, but if displaying in the terminal it is fine to include ansi-codes 9 | isf_stock() { 10 | is-fast \ 11 | --direct "https://finance.yahoo.com/quote/${1}/" \ 12 | --selector "section.container > h1, span.base" \ 13 | --piped \ 14 | --no-cache \ 15 | --color=always \ 16 | --style-element="span.txt-negative:fg=red" \ 17 | --style-element="span.txt-positive:fg=green" \ 18 | --pretty-print="margin:5" 19 | } 20 | 21 | # What is something? Give it a word or a name and it will return the first wikipedia paragraph of that thing. This will work if there is a wikipedia article with that 22 | # exact name. Works for most people and things. E.g. isf_what albert einstein 23 | isf_what() { 24 | is-fast \ 25 | --direct "en.wikipedia.org/wiki/${*}" \ 26 | --selector "div.mw-content-ltr > p" \ 27 | --color=always \ 28 | --piped \ 29 | --nth-element 1 \ 30 | --pretty-print="margin:20" \ 31 | --style-element="sup:size=half" 32 | # We get the first paragraph with content only from the child p's of div.mw-content-ltr 33 | # note: the first paragraph can be achieved with css selectors only, but is sometimes empty on the site - this then avoids any issues with the selected paragraph being empty.) 34 | } 35 | 36 | # Search stack overflow, showing only the question and answer text. Note must use --last for this, as the history output/order is not deterministic. 37 | isf_so() { 38 | QUESTION=$(is-fast ${*} --site "www.stackoverflow.com" --selector "div.question .js-post-body" --color=always --pretty-print="margin:20,title:Question" --piped --flash-cache) # Find the question content. 39 | ANSWER=$(is-fast --last --selector "div.accepted-answer .js-post-body" --color=always --pretty-print="margin:20,title:Answer" --piped --flash-cache) # Separately find the answer content. 40 | cat << EOF # Format as desired 41 | 42 | $QUESTION 43 | $ANSWER 44 | 45 | EOF 46 | } 47 | 48 | # Get a simple definition of a word. 49 | # NOTE capitalization is specific for ZSH - for BASH change to ${1^} 50 | isf_define() { 51 | is-fast \ 52 | --direct "www.merriam-webster.com/dictionary/${1}" \ 53 | --selector "div.sb" \ 54 | --nth-element 1 \ 55 | --color=always \ 56 | --pretty-print="margin:20,title:${(C)1}" \ 57 | --piped 58 | } 59 | 60 | # Check the current number of stars in the repo. 61 | isf_stars() { 62 | is-fast \ 63 | --direct "https://github.com/Magic-JD/is-fast" \ 64 | --selector "span#repo-stars-counter-star" \ 65 | --pretty-print="title:Current Stars,margin:5" \ 66 | --color=always \ 67 | --piped \ 68 | --no-cache 69 | } 70 | 71 | # Checks the google page to get the information for the info box, works for most conversions (with thanks to d3-X-t3r for this suggestion) 72 | # E.g. isf_quick 200f to c 73 | # isf_quick 30 GBP to USD 74 | # isf_quick Weather Berlin 75 | isf_quick() { 76 | is-fast \ 77 | --direct "https://www.google.com/search?q=${*}" \ 78 | --piped \ 79 | --selector="div.ezO2md" \ 80 | --ignore="a" \ 81 | --no-block \ 82 | --nth-element 1 \ 83 | --pretty-print="margin:20" \ 84 | --color=always \ 85 | --no-cache 86 | } 87 | -------------------------------------------------------------------------------- /src/actions/generate_config.rs: -------------------------------------------------------------------------------- 1 | use crate::config::files::config_path; 2 | use crate::config::load::DEFAULT_CONFIG; 3 | use std::fs; 4 | 5 | pub fn run() { 6 | println!("Generating config file..."); 7 | let config_path = config_path(); 8 | if config_path.exists() { 9 | eprintln!("Config file already exists at {config_path:?}"); 10 | } else if let Err(message) = fs::write(&config_path, DEFAULT_CONFIG) 11 | .map_err(|e| format!("Error writing config file: {e}")) 12 | { 13 | eprintln!("{message}"); 14 | } else { 15 | println!("Config file generated at {config_path:?}"); 16 | } 17 | } 18 | 19 | #[cfg(test)] 20 | mod tests { 21 | use super::*; 22 | use serial_test::serial; 23 | use std::env; 24 | use std::path::PathBuf; 25 | use tempfile::TempDir; 26 | 27 | #[test] 28 | #[serial] 29 | fn test_run_creates_config_file() { 30 | let temp_dir = TempDir::new().expect("Failed to create temp dir"); 31 | 32 | let fake_home = convert_to_canon(temp_dir); 33 | 34 | env::set_var("XDG_CONFIG_HOME", &fake_home); 35 | run(); 36 | 37 | let config_path = fake_home.join("is-fast/config.toml"); 38 | assert!(config_path.exists(), "Config file should be created"); 39 | } 40 | 41 | fn convert_to_canon(temp_dir: TempDir) -> PathBuf { 42 | if temp_dir.path().is_relative() { 43 | fs::canonicalize(temp_dir.path()).expect("Failed to canonicalize temp dir") 44 | } else { 45 | temp_dir.path().to_path_buf() 46 | } 47 | } 48 | 49 | #[test] 50 | #[serial] 51 | fn test_run_fails_if_config_exists() { 52 | use std::env; 53 | use std::fs; 54 | 55 | let temp_dir = TempDir::new().expect("Failed to create temp dir"); 56 | let fake_home = convert_to_canon(temp_dir); 57 | 58 | env::set_var("XDG_CONFIG_HOME", &fake_home); 59 | 60 | let config_path = fake_home.join("is-fast/config.toml"); 61 | fs::create_dir_all(config_path.parent().unwrap()).unwrap(); 62 | fs::write(&config_path, "existing content").unwrap(); 63 | 64 | let output = std::panic::catch_unwind(run); 65 | 66 | assert!(output.is_ok(), "Function should not panic"); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/actions/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod generate_config; 2 | pub mod prepare_pages; 3 | -------------------------------------------------------------------------------- /src/actions/prepare_pages.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::command::OpenArgs; 2 | use crate::config::load::Config; 3 | use crate::database::history_database::get_latest_history; 4 | use crate::errors::error::IsError; 5 | use crate::search_engine::link::HtmlSource::{FileSource, LinkSource}; 6 | use crate::search_engine::link::{File, HtmlSource, Link}; 7 | use crate::search_engine::search::find_links; 8 | use once_cell::sync::Lazy; 9 | 10 | static SITE: Lazy> = Lazy::new(Config::get_search_site); 11 | 12 | pub fn prepare_pages(query: OpenArgs) -> Result, IsError> { 13 | let mut sources: Vec = vec![]; 14 | if query.last { 15 | if let Some(history) = get_latest_history()? { 16 | sources.push(LinkSource(Link::new(&history.url))); 17 | } 18 | } 19 | if let Some(file_location) = query.file { 20 | sources.push(FileSource(File::new( 21 | file_location, 22 | query.url.unwrap_or_default(), 23 | ))); 24 | } 25 | for url in query.direct { 26 | sources.push(LinkSource(Link::new(&url))); 27 | } 28 | if let Some(search_term) = query.query.map(|q| q.join(" ")) { 29 | let site = SITE 30 | .clone() 31 | .map(|s| format!("site:{s}")) 32 | .unwrap_or_default(); 33 | find_links(format!("{search_term} {site}").trim())? 34 | .into_iter() 35 | .map(LinkSource) 36 | .for_each(|source| sources.push(source)); 37 | } 38 | Ok(sources) 39 | } 40 | -------------------------------------------------------------------------------- /src/app/enum_values.rs: -------------------------------------------------------------------------------- 1 | use crate::app::enum_values::App::{Text, Tui}; 2 | use crate::app::text::TextApp; 3 | use crate::app::tui::TuiApp; 4 | use crate::search_engine::link::HtmlSource; 5 | use enum_dispatch::enum_dispatch; 6 | 7 | #[enum_dispatch] 8 | pub enum App { 9 | Tui(TuiApp), 10 | Text(TextApp), 11 | } 12 | 13 | impl App { 14 | pub fn from_type(piped: bool) -> Self { 15 | if piped { 16 | Text(TextApp::new()) 17 | } else { 18 | Tui(TuiApp::new()) 19 | } 20 | } 21 | } 22 | 23 | #[enum_dispatch(App)] 24 | pub trait HistoryViewer { 25 | fn show_history(&mut self) -> Option; 26 | } 27 | #[enum_dispatch(App)] 28 | pub trait PageViewer { 29 | fn show_pages(&mut self, pages: &[HtmlSource]); 30 | } 31 | 32 | #[enum_dispatch(App)] 33 | pub trait AppFunctions { 34 | fn loading(&mut self); 35 | fn shutdown(&mut self); 36 | fn shutdown_with_error(&mut self, error: &str) -> !; 37 | } 38 | 39 | impl AppFunctions for TextApp { 40 | fn loading(&mut self) { 41 | // Nothing needs to be shown when the app is loading. 42 | } 43 | 44 | fn shutdown(&mut self) { 45 | // There is nothing that needs to be shutdown here. 46 | } 47 | 48 | fn shutdown_with_error(&mut self, error: &str) -> ! { 49 | // Log to user OK here because we are shutting down and they should know why 50 | eprintln!("{error}"); 51 | std::process::exit(1); 52 | } 53 | } 54 | 55 | impl AppFunctions for TuiApp { 56 | fn loading(&mut self) { 57 | self.display.loading(); 58 | } 59 | 60 | fn shutdown(&mut self) { 61 | self.display.shutdown(); 62 | } 63 | 64 | fn shutdown_with_error(&mut self, error: &str) -> ! { 65 | self.display.shutdown_with_error(error); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/app/event_loop.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event; 2 | use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; 3 | 4 | pub fn history_event_loop() -> HistoryAction { 5 | if let Ok(event::Event::Key(KeyEvent { 6 | code, 7 | kind: KeyEventKind::Press, 8 | .. 9 | })) = event::read() 10 | { 11 | return match code { 12 | KeyCode::Esc => HistoryAction::Exit, 13 | KeyCode::Up => HistoryAction::Up, 14 | KeyCode::Down => HistoryAction::Down, 15 | KeyCode::Enter => HistoryAction::Open, 16 | KeyCode::Delete => HistoryAction::Delete, 17 | KeyCode::Char(char) => HistoryAction::Text(char), 18 | KeyCode::Backspace => HistoryAction::Backspace, 19 | KeyCode::Tab => HistoryAction::ChangeSearch, 20 | _ => HistoryAction::Continue, 21 | }; 22 | } 23 | HistoryAction::Continue 24 | } 25 | 26 | pub enum HistoryAction { 27 | Exit, 28 | Continue, 29 | Open, 30 | Up, 31 | Down, 32 | Delete, 33 | Text(char), 34 | Backspace, 35 | ChangeSearch, 36 | } 37 | pub fn page_event_loop() -> PageAction { 38 | // As the next page load can take some time especially this can cause an issue if the user 39 | // enters input while in the loading screen. To fix this we drain the buffer before we read the 40 | // next event. 41 | drain_buffer(); 42 | if let Ok(event::Event::Key(KeyEvent { 43 | code, 44 | modifiers, 45 | kind: KeyEventKind::Press, 46 | .. 47 | })) = event::read() 48 | { 49 | return match code { 50 | KeyCode::Char('q') | KeyCode::Esc => PageAction::Exit, 51 | KeyCode::Char('n') | KeyCode::Right => PageAction::Next, 52 | KeyCode::Char('b') | KeyCode::Left => PageAction::Previous, 53 | KeyCode::Down | KeyCode::Char('j') => PageAction::Down, 54 | KeyCode::Up | KeyCode::Char('k') => PageAction::Up, 55 | KeyCode::PageUp => PageAction::PageUp, 56 | KeyCode::Char('u') if modifiers.contains(KeyModifiers::CONTROL) => PageAction::PageUp, 57 | KeyCode::PageDown => PageAction::PageDown, 58 | KeyCode::Char('d') if modifiers.contains(KeyModifiers::CONTROL) => PageAction::PageDown, 59 | KeyCode::Char('o') => PageAction::Open, 60 | _ => PageAction::Continue, 61 | }; 62 | } 63 | PageAction::Continue 64 | } 65 | 66 | fn drain_buffer() { 67 | while event::poll(std::time::Duration::from_secs(0)).unwrap_or(false) { 68 | let _ = event::read(); 69 | } 70 | } 71 | 72 | pub(crate) enum PageAction { 73 | Exit, 74 | Open, 75 | Up, 76 | Down, 77 | PageUp, 78 | PageDown, 79 | Next, 80 | Previous, 81 | Continue, 82 | } 83 | -------------------------------------------------------------------------------- /src/app/history.rs: -------------------------------------------------------------------------------- 1 | use crate::app::enum_values::HistoryViewer; 2 | use crate::app::event_loop::{history_event_loop, HistoryAction}; 3 | use crate::app::text::TextApp; 4 | use crate::app::tui::TuiApp; 5 | use crate::database::history_database::{get_history, HistoryData}; 6 | use crate::pipe::history::pipe_history; 7 | use crate::search_engine::link::HtmlSource::LinkSource; 8 | use crate::search_engine::link::{HtmlSource, Link}; 9 | use crate::tui::history_content::HistoryContent; 10 | use ratatui::widgets::TableState; 11 | 12 | #[derive(Clone)] 13 | pub enum SearchOn { 14 | Title, 15 | Url, 16 | } 17 | 18 | impl HistoryViewer for TuiApp { 19 | fn show_history(&mut self) -> Option { 20 | let history = 21 | get_history().unwrap_or_else(|_| self.display.shutdown_with_error("No history found.")); 22 | let table_state = TableState::default(); 23 | let mut history_content = HistoryContent::new( 24 | history, 25 | String::new(), 26 | SearchOn::Title, 27 | self.display.area(), 28 | table_state, 29 | ); 30 | { 31 | self.display 32 | .render(history_content.create_widgets(self.display.area())); 33 | } 34 | loop { 35 | match history_event_loop() { 36 | HistoryAction::Continue => continue, 37 | HistoryAction::Exit => { 38 | self.display.shutdown(); 39 | break; 40 | } 41 | HistoryAction::Open => { 42 | return current_link( 43 | &history_content.current_history, 44 | &history_content.table_state, 45 | ); 46 | } 47 | HistoryAction::Up => history_content.scroll_up(), 48 | HistoryAction::Down => history_content.scroll_down(), 49 | HistoryAction::Delete => history_content.remove_current(), 50 | HistoryAction::Text(char) => { 51 | history_content.add_char(char); 52 | } 53 | HistoryAction::Backspace => history_content.remove_char(), 54 | HistoryAction::ChangeSearch => { 55 | history_content.change_search(); 56 | } 57 | } 58 | { 59 | self.display 60 | .render(history_content.create_widgets(self.display.area())); 61 | } 62 | } 63 | None 64 | } 65 | } 66 | 67 | impl HistoryViewer for TextApp { 68 | fn show_history(&mut self) -> Option { 69 | let history = 70 | get_history().unwrap_or_else(|_| Self::terminating_error("Cannot access history.")); 71 | pipe_history(history).unwrap_or_else(|_| eprintln!("Pipe broken!")); 72 | None 73 | } 74 | } 75 | 76 | fn current_link(history: &[HistoryData], state: &TableState) -> Option { 77 | let idx = state.selected().unwrap_or(0); 78 | history 79 | .iter() 80 | .collect::>() 81 | .get(idx) 82 | .map(|history_data| LinkSource(Link::new(&history_data.url))) 83 | } 84 | -------------------------------------------------------------------------------- /src/app/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod enum_values; 2 | pub mod event_loop; 3 | pub mod history; 4 | mod page; 5 | pub mod text; 6 | pub mod tui; 7 | -------------------------------------------------------------------------------- /src/app/page.rs: -------------------------------------------------------------------------------- 1 | use crate::app::enum_values::PageViewer; 2 | use crate::app::event_loop::{page_event_loop, PageAction}; 3 | use crate::app::text::TextApp; 4 | use crate::app::tui::TuiApp; 5 | use crate::config::load::{Config, Scroll}; 6 | use crate::database::history_database::add_history; 7 | use crate::search_engine::link::HtmlSource; 8 | use crate::transform::page::PageExtractor; 9 | use crate::transform::pretty_print::conditional_formatting; 10 | use crate::tui::page_content::PageContent; 11 | 12 | impl PageViewer for TuiApp { 13 | fn show_pages(&mut self, pages: &[HtmlSource]) { 14 | let height = self.display.height() - 2; // Subtract for the border 15 | let mut scroll: u16 = 0; 16 | if pages.is_empty() { 17 | self.display.shutdown_with_error("No results found."); 18 | } 19 | let mut index = 0; 20 | let mut page_content = PageContent::new(pages, self.display.area()); 21 | self.display 22 | .render(page_content.create_widgets(index, scroll, pages, self.display.area())); 23 | loop { 24 | match page_event_loop() { 25 | PageAction::Exit => break, 26 | PageAction::Next => { 27 | if index < pages.len() - 1 { 28 | self.display.loading(); 29 | scroll = 0; 30 | index += 1; 31 | } 32 | } 33 | PageAction::Previous => { 34 | if index > 0 { 35 | self.display.loading(); 36 | scroll = 0; 37 | index -= 1; 38 | } 39 | } 40 | PageAction::Down => { 41 | scroll = scroll.saturating_add(1); 42 | } 43 | PageAction::Up => { 44 | scroll = scroll.saturating_sub(1); 45 | } 46 | PageAction::PageUp => match Config::get_scroll() { 47 | Scroll::Full => scroll = scroll.saturating_sub(height), 48 | Scroll::Half => scroll = scroll.saturating_sub(height / 2), 49 | Scroll::Discrete(amount) => scroll = scroll.saturating_sub(*amount), 50 | }, 51 | PageAction::PageDown => match Config::get_scroll() { 52 | Scroll::Full => scroll = scroll.saturating_add(height), 53 | Scroll::Half => scroll = scroll.saturating_add(height / 2), 54 | Scroll::Discrete(amount) => scroll = scroll.saturating_add(*amount), 55 | }, 56 | PageAction::Open => { 57 | self.open_link(index, pages) 58 | .unwrap_or_else(|err| self.display.shutdown_with_error(&err.to_string())); 59 | } 60 | PageAction::Continue => continue, 61 | } 62 | self.display.render(page_content.create_widgets( 63 | index, 64 | scroll, 65 | pages, 66 | self.display.area(), 67 | )); 68 | } 69 | self.display.shutdown(); 70 | } 71 | } 72 | 73 | impl PageViewer for TextApp { 74 | fn show_pages(&mut self, pages: &[HtmlSource]) { 75 | let page_extracter: PageExtractor = PageExtractor::new(); 76 | match pages { 77 | [page, ..] => { 78 | let (title, content) = page_extracter.get_text(page); 79 | if let HtmlSource::LinkSource(link) = page { 80 | if *Config::get_history_enabled() { 81 | let url = &link.url; 82 | add_history(&title, url).unwrap_or_else(|err| { 83 | log::error!("Failed to add history for page {title} ({url}) {err}"); 84 | }); 85 | } 86 | } 87 | log::debug!("Outputting page {title} to terminal"); 88 | println!( 89 | "{}", 90 | conditional_formatting(&title, content, Config::get_pretty_print()) 91 | ); 92 | } 93 | [] => eprintln!("No links found, no error detected."), 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/app/text.rs: -------------------------------------------------------------------------------- 1 | pub struct TextApp {} 2 | 3 | impl TextApp { 4 | pub fn new() -> Self { 5 | Self {} 6 | } 7 | 8 | pub fn terminating_error(error_message: &str) -> ! { 9 | eprintln!("{error_message}"); 10 | std::process::exit(1) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/tui.rs: -------------------------------------------------------------------------------- 1 | use crate::config::load::Config; 2 | use crate::errors::error::IsError; 3 | use crate::errors::error::IsError::General; 4 | use crate::search_engine::link::HtmlSource; 5 | use crate::tui::display::Display; 6 | use std::process::Command; 7 | 8 | pub struct TuiApp { 9 | pub(crate) display: Display, 10 | } 11 | 12 | impl TuiApp { 13 | pub fn new() -> Self { 14 | let mut display = Display::new(); 15 | display.loading(); 16 | Self { display } 17 | } 18 | 19 | pub fn open_link(&mut self, index: usize, pages: &[HtmlSource]) -> Result<(), IsError> { 20 | let url = pages 21 | .get(index) 22 | .and_then(|page| Some(page.get_url()).filter(|s| !s.is_empty())) 23 | .ok_or(General(String::from("Page doesn't have a url")))?; 24 | if let Some(tool) = Config::get_open_command() { 25 | match tool { 26 | Err(error) => return Err(General(error.to_string())), 27 | Ok(command) => { 28 | let bin = command[0].as_str(); 29 | let args = &command[1..]; 30 | Command::new(bin) 31 | .args(args) 32 | .arg(url) 33 | .status() 34 | .map_err(|e| General(format!("{e} - you are trying to open with '{bin}' - confirm running this tool with url {url} externally for more information")))?; 35 | } 36 | } 37 | // If there is a user defined tool to open, use that 38 | } else { 39 | // Use system open tool 40 | open::that(url).map_err(|err| General(err.to_string()))?; 41 | } 42 | self.display.refresh(); // Refresh display to protect against screen issues. 43 | Ok(()) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/cli/command.rs: -------------------------------------------------------------------------------- 1 | use clap::{ArgAction, Parser, ValueEnum}; 2 | 3 | #[derive(Debug, Clone, ValueEnum)] 4 | pub enum ColorMode { 5 | Tui, 6 | Always, 7 | Never, 8 | } 9 | 10 | #[derive(Debug, PartialEq, Clone, ValueEnum, Default)] 11 | pub enum CacheMode { 12 | #[default] 13 | Never, 14 | Read, 15 | Write, 16 | ReadWrite, 17 | Flash, 18 | } 19 | 20 | #[derive(Debug, PartialEq, Clone, ValueEnum, Default)] 21 | pub enum LogLevel { 22 | #[default] 23 | Error, 24 | Warn, 25 | Info, 26 | Debug, 27 | Trace, 28 | } 29 | 30 | #[derive(Debug, Parser)] 31 | pub struct OpenArgs { 32 | #[arg(help = "The search query to extract content from websites")] 33 | pub query: Option>, 34 | 35 | #[arg(short = 'f', long = "file", help = "Path to the HTML file to render")] 36 | pub file: Option, 37 | 38 | #[arg( 39 | short = 'u', 40 | long = "url", 41 | requires = "file", 42 | help = "Optional URL to associate with the file" 43 | )] 44 | pub url: Option, 45 | 46 | #[arg(short = 'd', long = "direct", help = "Open the given URL/s directly")] 47 | pub direct: Vec, 48 | 49 | #[arg(long, help = "Show last viewed page")] 50 | pub last: bool, 51 | 52 | #[arg(long = "site", help = "Show results only from a specific site.")] 53 | pub site: Option, 54 | } 55 | 56 | #[derive(Debug, Parser)] 57 | pub struct SelectionArgs { 58 | #[arg( 59 | short = 's', 60 | long = "selector", 61 | help = "Use the given CSS selector for this query." 62 | )] 63 | pub selector: Option, 64 | 65 | #[arg(long = "ignore", help = "Ignore the given html element/s.")] 66 | pub ignore: Vec, 67 | 68 | #[arg( 69 | long = "no-block", 70 | help = "Do not put block elements on separate lines." 71 | )] 72 | pub no_block: bool, 73 | 74 | #[arg( 75 | long = "nth-element", 76 | help = "Show only the nth element matching the selector" 77 | )] 78 | pub nth_element: Vec, 79 | } 80 | 81 | #[derive(Debug, Parser)] 82 | pub struct CacheArgs { 83 | #[arg( 84 | long, 85 | help = "Apply caching for the given search (shorthand for --cache-mode=read-write)" 86 | )] 87 | pub cache: bool, 88 | 89 | #[arg( 90 | long, 91 | help = "Disable caching for the given search (shorthand for --cache-mode=never)" 92 | )] 93 | pub no_cache: bool, 94 | 95 | #[arg(long, value_enum, help = "Set cache mode")] 96 | pub cache_mode: Option, 97 | 98 | #[arg( 99 | long, 100 | help = "Enable caching with an extremely short TTL (shorthand for --cache-mode=flash)" 101 | )] 102 | pub flash_cache: bool, 103 | } 104 | 105 | #[derive(Debug, Parser)] 106 | pub struct HistoryArgs { 107 | #[arg(long, help = "Show previously viewed pages")] 108 | pub history: bool, 109 | 110 | #[arg(long, help = "Disable history for the given search")] 111 | pub no_history: bool, 112 | } 113 | 114 | #[derive(Debug, Parser)] 115 | pub struct OutputArgs { 116 | #[arg(long, help = "Output the result to standard out")] 117 | pub piped: bool, 118 | 119 | #[arg(long, value_enum, help = "Set color mode")] 120 | pub color: Option, 121 | 122 | #[arg( 123 | long, 124 | help = "Additional display configuration when printing to the terminal. Available options: wrap, margin, title" 125 | )] 126 | pub pretty_print: Vec, 127 | 128 | #[arg(long, help = "Apply the given style to an element.")] 129 | pub style_element: Vec, 130 | } 131 | 132 | #[derive(Debug, Parser)] 133 | pub struct TaskArgs { 134 | #[arg(long, action = ArgAction::SetTrue, help = "Generate a default configuration file")] 135 | pub generate_config: bool, 136 | 137 | #[arg(long, help = "Wipe all data")] 138 | pub clear_all: bool, 139 | 140 | #[arg(long, help = "Wipe history")] 141 | pub clear_history: bool, 142 | 143 | #[arg(long, help = "Wipe the cache")] 144 | pub clear_cache: bool, 145 | } 146 | 147 | #[derive(Debug, Parser)] 148 | pub struct LogArgs { 149 | #[arg(long, help = "Activate logging")] 150 | pub log: bool, 151 | 152 | #[arg(long, value_enum, help = "Set log level")] 153 | pub log_level: Option, 154 | } 155 | 156 | #[derive(Debug, Parser)] 157 | #[command(name = "is-fast")] 158 | #[command(about = "A fast content extractor for terminal-based internet searches")] 159 | #[command(version = env!("CARGO_PKG_VERSION"), author = "Joseph Daunt")] 160 | #[command(after_help = "For more details, visit https://github.com/Magic-JD/is-fast")] 161 | pub struct Cli { 162 | #[command(flatten)] 163 | pub open: OpenArgs, 164 | 165 | #[command(flatten)] 166 | pub selection: SelectionArgs, 167 | 168 | #[command(flatten)] 169 | pub cache: CacheArgs, 170 | 171 | #[command(flatten)] 172 | pub history: HistoryArgs, 173 | 174 | #[command(flatten)] 175 | pub output: OutputArgs, 176 | 177 | #[command(flatten)] 178 | pub task: TaskArgs, 179 | 180 | #[command(flatten)] 181 | pub log: LogArgs, 182 | } 183 | -------------------------------------------------------------------------------- /src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod command; 2 | pub mod parser; 3 | -------------------------------------------------------------------------------- /src/config/alternate_headers.toml: -------------------------------------------------------------------------------- 1 | [headers] 2 | "User-Agent" = "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0" 3 | "Accept" = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" 4 | "Accept-Language" = "en-US,en;q=0.5" 5 | "Accept-Encoding" = "gzip, deflate, br, zstd" 6 | "Referer" = "https://www.google.com/" 7 | "Connection" = "keep-alive" 8 | "Upgrade-Insecure-Requests" = "1" 9 | "Sec-Fetch-Dest" = "document" 10 | "Sec-Fetch-Mode" = "navigate" 11 | "Sec-Fetch-Site" = "cross-site" 12 | "Sec-Fetch-User" = "?1" 13 | "Priority" = "u=0, i" 14 | "TE" = "trailers" -------------------------------------------------------------------------------- /src/config/config.toml: -------------------------------------------------------------------------------- 1 | # NON SITE SPECIFIC 2 | # These configurations can't be configured differently for different sites. 3 | 4 | # General display options. 5 | [display] 6 | # The color of the border. 7 | border_color = "#89b4fa" 8 | # A percentage of the page width to be applied on either side. 9 | page_margin = 10 10 | # The amount page down/up scrolls - can be set to full, half, or a number of lines (as a string) 11 | page_scroll = "full" 12 | # When colors should be shown for the page results. Options are in the Tui only, Never or Always (including piped values) 13 | # This setting can be overriden by the --color flag. 14 | color_mode = "tui" 15 | 16 | # Setting for the history page 17 | [history] 18 | # Sets the color of the title on the history entries. 19 | title_color = "#89b4fa" 20 | # Sets the url color on the history entries. 21 | url_color = "#bac2de" 22 | # Sets the color for the time on the history entries. 23 | time_color = "#f2cdcd" 24 | # Sets the color of the search text. 25 | text_color = "#74c7ec" 26 | # Sets the type of search - valid options Fuzzy | Substring | Exact. 27 | # Defaults to fuzzy. 28 | search_type = "fuzzy" 29 | # Set this to `false` to stop tracking the sites you have visited. 30 | enabled = true 31 | 32 | [misc] 33 | # open_tool = "firefox" # Uses system open as default, only uncomment if you want to override this behaviour 34 | 35 | # EXPERIMENTAL 36 | # Set text size. Text size is only supported for kitty terminal 0.40.0+. 37 | # If you are not running kitty 0.40.0+ raw (i.e. without screen/tmux/zellig), enabling this will not work. 38 | # If running in an unsupported terminal or screen, this will cause the resized text to disappear. 39 | # Text wrap will not work correctly if the resized text is larger than the wrap. 40 | # text_size_supported = false 41 | 42 | # Search settings. 43 | [search] 44 | # Available options: 45 | # - "duckduckgo" (default) - Uses DuckDuckGo for searches. 46 | # - "google" - Uses Google Custom Search (requires API setup). 47 | # - "kagi" - Uses Kagi Search (requires API setup) 48 | # 49 | # If using Google Search, you must configure the API: 50 | # 1. Enable the Google Custom Search API in the Google Cloud Console. 51 | # 2. Generate an API Key. 52 | # 3. Create a Custom Search Engine and obtain its Search Engine ID. 53 | # 4. Set the following environment variables: 54 | # export IS_FAST_GOOGLE_API_KEY="your_api_key_here" 55 | # export IS_FAST_GOOGLE_SEARCH_ENGINE_ID="your_search_engine_id_here" 56 | # 57 | # If using Kagi search you must configure the api: https://help.kagi.com/kagi/api/search.html 58 | # 1. Obtain access to the API (Currently in closed Beta) 59 | # 2. Generate an API key. 60 | # 3. Set the following environment variable: 61 | # export IS_FAST_KAGI_API_KEY 62 | # 63 | # To add a custom search engine, fork the repository and follow the 64 | # instructions in src/search/search_type.rs. 65 | engine = "duckduckgo" 66 | 67 | # Uncommnt to restrict search results to only the given domain. Only compatable with duckduckgo search. 68 | # Can be overriden with the --site flag. 69 | # site = "domain.name.org" 70 | 71 | # Sets the timeout for the search or page in seconds. 72 | timeout = 4 73 | 74 | # This determines which part of the page will be selected. No good one for a site you use? Add your own, and make a pull 75 | # request @https://github.com/Magic-JD/is-fast. 76 | [selectors] 77 | "*wikipedia.org*" = "div.mw-content-ltr > *:not(table, figure, div.hatnote, div.floatright)" 78 | "*www.baeldung.com*" = ".post-content" 79 | "*www.w3schools.com*" = "#main" 80 | "*linuxhandbook.com*" = "article" 81 | "*docs.spring.io*" = "article" 82 | "*stackoverflow.com*" = "a.question-hyperlink, time, div.user-action-time, div.js-vote-count, div.js-post-body, div.comment-body" 83 | "*github.com/*/blob/*" = ".react-code-line-contents" 84 | "*github.com/*/issues/*" = "main" 85 | "*github.com*" = ".markdown-body" 86 | "*apnews.com*" = ".RichTextStoryBody" 87 | "*geeksforgeeks.org*" = ".text" 88 | "*programiz.com*" = ".editor-contents" 89 | "*merriam-webster.com*" = "div.vg" 90 | "*developer.mozilla.org*" = "main.main-content" 91 | "*realpython.com*" = "div.article-body > *:not(div.sidebar-module)" 92 | "*docs.oracle.com*/tutorial/*" = "div#PageContent" 93 | "*www.simplilearn.com*" = "article" 94 | "*wiki.python.org*" = "div#content" 95 | "*www.coursera.org/tutorials*" = "div.rc-Body" 96 | "*pythonexamples.org*" = "div#entry-content" 97 | "*hackr.io/blog*" = "article" 98 | "*www.tutorialspoint.com*" = "div#mainContent" 99 | "*techbeamers.com*" = "div.entry-content > *:not(div.ruby-table-contents)" 100 | "*old.reddit.com*" = "a.title, h1.redditname, span.subscribers, p.users-online" 101 | "*old.reddit.com/r/*/comments/*" = "a.title, a.author, div.entry > form, div.expando > form" 102 | "*docs.rs*" = "section#main-content" 103 | 104 | # This defines the other configs that should be appended for the matching sites. 105 | # These configs should be defined in the same directory as your default config. 106 | [custom_config] 107 | "*finance.yahoo.com*" = ["alternate_headers.toml"] 108 | "*stackoverflow.com*" = ["alternate_headers.toml"] 109 | 110 | 111 | # SITE SPECIFIC - This section can be overriden in a custom config file. 112 | 113 | 114 | # Styles for each element 115 | # Example: 116 | # [styles.example] 117 | # fg = "Blue" # Sets the foreground (text) color 118 | # bg = "#D3D3D3" # Sets the background color using a hex code (Light Gray) 119 | # bg = "rgb(0, 0, 0)" # would also be a valud option. 120 | # bold = true # Makes text bold 121 | # italic = true # Makes text italic 122 | # underlined = true # Underlines the text 123 | # crossed_out = true # Strikes through the text 124 | # dim = true # Makes text dimmer 125 | 126 | [styles.h1] 127 | bold = true 128 | size = "Triple" 129 | 130 | [styles.h2] 131 | bold = true 132 | size = "Double" 133 | 134 | [styles.h3] 135 | bold = true 136 | size = "Double" 137 | 138 | [styles.h4] 139 | bold = true 140 | size = "Double" 141 | 142 | [styles.a] 143 | fg = "Cyan" 144 | 145 | [styles.img] 146 | fg = "Red" 147 | bold = true 148 | 149 | [styles.em] 150 | italic = true 151 | 152 | [styles.i] 153 | italic = true 154 | 155 | [styles.strong] 156 | bold = true 157 | 158 | [styles.b] 159 | bold = true 160 | 161 | [styles.blockquote] 162 | fg = "Gray" 163 | italic = true 164 | 165 | [styles.del] 166 | crossed_out = true 167 | 168 | [styles.ins] 169 | underlined = true 170 | 171 | [styles.mark] 172 | fg = "Black" 173 | bg = "Yellow" 174 | 175 | [styles.small] 176 | fg = "Gray" 177 | 178 | [styles.sub] 179 | fg = "Gray" 180 | dim = true 181 | 182 | [styles.sup] 183 | fg = "Gray" 184 | dim = true 185 | 186 | [styles.kbd] 187 | fg = "White" 188 | bg = "DarkGray" 189 | 190 | [styles.var] 191 | fg = "Red" 192 | italic = true 193 | 194 | [styles.samp] 195 | fg = "Magenta" 196 | 197 | [styles.u] 198 | underlined = true 199 | 200 | [styles.li] 201 | bold = true 202 | 203 | [styles.dt] 204 | bold = true 205 | 206 | [styles.dd] 207 | fg = "Gray" 208 | 209 | 210 | # This defines the headers that will be included when fetching the page. 211 | [headers] 212 | "Accept" = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8" 213 | "Accept-Language" = "en-US,en;q=0.9" 214 | "User-Agent" = "Lynx/2.8.8dev.3 libwww-FM/2.14 SSL-MM/1.4.1" 215 | 216 | 217 | [format] 218 | # All elements from this list are ignored. 219 | # Limited CSS Selectors are supported (.class and #id), in the format tag#id.class.additional-class. 220 | # E.G. 221 | # div#center will ignore only divs that are marked center. 222 | # div.this.that will ignore divs with the class this or the class that. 223 | # .this.that will ignore any element with the class this OR the class that. 224 | # #that will ignore any element with the id that. 225 | # div#center.this will ignore any div with the id center OR the class this. 226 | # div.this#center is INVALID and will not work. 227 | # .this#center is INVALID and will not work. 228 | ignored_tags = ["script", "style", "noscript", "head", "title", "meta", "input", "button", "svg", "nav", "footer", "header", "aside", "gfg-tab"] 229 | 230 | # When this is true the new tags will replace the default tags rather than appending to them. 231 | # clear_existing_ignored_tags = false 232 | 233 | # Adding an element to this list will ensure that every instance of that element sits on its own line. 234 | # Limited CSS selectors are supported as above. 235 | block_elements = ["p", "div", "table", "article", "img", "section", "pre", "blockquote", "ul", "ol", "dl", "dt", "dd", "li", "h1", "h2", "h3", "h4", "h5", "h6"] 236 | 237 | # When this is true the new tags will replace the default tags rather than appending to them. 238 | # clear_existing_block_tags = false 239 | 240 | # These elements should be indented when nested. 241 | # Limited CSS selectors are supported as above. 242 | indent_elements = ["li"] 243 | 244 | # When this is true the new tags will replace the default tags rather than appending to them. 245 | # clear_existing_indent_tags = false 246 | 247 | 248 | # Section relating to syntax highlighting. 249 | [syntax] 250 | # This is the language that will be used for syntax highlighting if it cannot be deduced from the CSS of the page. 251 | default_language = "rust" 252 | # Valid themes 253 | # InspiredGitHub 254 | # Solarized (dark) 255 | # Solarized (light) 256 | # base16-eighties.dark 257 | # base16-mocha.dark 258 | # base16-ocean.dark 259 | # base16-ocean.light 260 | theme = "base16-ocean.dark" 261 | 262 | 263 | # Caching stores the raw html that is called associated with the url. When enabled it provides results you have seen 264 | # before much faster. It is especially useful for scripts where you might want to select a number of different elements 265 | # from the same page by repeatedly calling is fast on that page with different selectors. 266 | [cache] 267 | # The mode to use for caching. Caching is disabled by default. Options are disabled, read, write, and readwrite. 268 | cache_mode = "disabled" 269 | # Max size of the cache if it is used. During tests, it took around 2MB per 100 entries, but will vary with page size. 270 | max_size = 100 271 | # How long the cached value should be valid in seconds. NOTE: the cached data is stored with the TTL being added to the 272 | # cached time. This means that if you change this to a longer value then change it back again the longer lived data might 273 | # persist. If you want to remove that data you should use the --clear-cache flag. 274 | ttl = 300 275 | 276 | -------------------------------------------------------------------------------- /src/config/files.rs: -------------------------------------------------------------------------------- 1 | use dirs::{config_dir, data_dir}; 2 | use std::path::PathBuf; 3 | use std::{env, fs}; 4 | 5 | pub fn config_location() -> PathBuf { 6 | env_default_path("IS_FAST_CONFIG_DIR", config_dir) 7 | } 8 | 9 | pub fn config_path() -> PathBuf { 10 | let mut path = config_location(); 11 | path.push("config.toml"); 12 | path 13 | } 14 | 15 | pub fn log_path() -> PathBuf { 16 | let mut path = env_default_path("IS_FAST_LOG_DIR", config_dir); 17 | path.push("is-fast.log"); 18 | path 19 | } 20 | 21 | pub fn database_path() -> PathBuf { 22 | let mut path = env_default_path("IS_FAST_DATABASE_DIR", data_dir); 23 | path.push("is-fast.db"); 24 | path 25 | } 26 | 27 | fn env_default_path(env_var_name: &str, default: fn() -> Option) -> PathBuf { 28 | env::var(env_var_name) 29 | .map(PathBuf::from) 30 | .ok() 31 | .or_else(|| fs_default_path(default)) 32 | .expect("Unable to determine the path.") 33 | } 34 | 35 | fn fs_default_path(default: fn() -> Option) -> Option { 36 | default() 37 | .map(|mut path| { 38 | path.push("is-fast"); 39 | path 40 | }) 41 | .inspect(|path| fs::create_dir_all(path).expect("Failed to create directory")) 42 | } 43 | -------------------------------------------------------------------------------- /src/config/format.rs: -------------------------------------------------------------------------------- 1 | use crate::config::color_conversion::Style; 2 | use scraper::ElementRef; 3 | use std::collections::{BTreeMap, HashMap, HashSet}; 4 | 5 | #[derive(Debug, Clone, Default, Eq, PartialEq, Hash)] 6 | pub enum TagData { 7 | #[default] 8 | None, 9 | Styled(Style), 10 | } 11 | 12 | #[derive(Debug, Clone, Default, Eq, PartialEq, Hash)] 13 | pub struct TagDataIdentifier { 14 | data: TagData, 15 | classes: BTreeMap, 16 | ids: BTreeMap, 17 | } 18 | 19 | #[derive(Debug, Clone, Default, Eq, PartialEq)] 20 | pub struct TagIdentifier { 21 | unconditional: bool, 22 | classes: HashSet, 23 | ids: HashSet, 24 | } 25 | 26 | #[derive(Debug, Clone, Default)] 27 | pub struct FormatConfig { 28 | pub ignored_tags: HashMap, 29 | pub block_elements: HashMap, 30 | pub indent_elements: HashMap, 31 | pub tag_styles: HashMap, 32 | } 33 | 34 | impl FormatConfig { 35 | pub fn new( 36 | ignored_tags: HashSet, 37 | block_elements: HashSet, 38 | indent_elements: HashSet, 39 | tag_styles: HashMap, 40 | ) -> Self { 41 | let ignored_tags_map = Self::build_map_from_selectors(ignored_tags); 42 | let block_elements_map = Self::build_map_from_selectors(block_elements); 43 | let indent_elements_map = Self::build_map_from_selectors(indent_elements); 44 | let tag_styles_map = Self::build_data_map_from_selectors( 45 | tag_styles 46 | .into_iter() 47 | .map(|(str, sty)| (str, TagData::Styled(sty))) 48 | .collect(), 49 | ); 50 | Self { 51 | ignored_tags: ignored_tags_map, 52 | block_elements: block_elements_map, 53 | indent_elements: indent_elements_map, 54 | tag_styles: tag_styles_map, 55 | } 56 | } 57 | 58 | fn build_map_from_selectors(ignored_tags: HashSet) -> HashMap { 59 | let mut ignored_tags_map: HashMap = HashMap::new(); 60 | for tag in ignored_tags { 61 | let mut class_split = tag.split('.'); 62 | let tag = class_split.next().unwrap_or_else(|| { 63 | log::error!("Invalid css selector - must be of the format TAG#ID.CLASS, {tag}"); 64 | "" 65 | }); 66 | let classes = class_split.collect::>(); 67 | let mut id_split = tag.split('#'); 68 | let tag = id_split.next().unwrap_or_else(|| { 69 | log::error!("Invalid css selector - must be of the format TAG#ID.CLASS, {tag}"); 70 | "" 71 | }); 72 | let id = id_split.next(); 73 | let tag_identifier = ignored_tags_map.entry(tag.to_string()).or_default(); 74 | if classes.is_empty() && id.is_none() { 75 | tag_identifier.unconditional = true; 76 | } 77 | tag_identifier 78 | .classes 79 | .extend(classes.into_iter().map(String::from)); 80 | if let Some(id) = id { 81 | tag_identifier.ids.insert(id.to_string()); 82 | } 83 | } 84 | ignored_tags_map 85 | } 86 | 87 | fn build_data_map_from_selectors( 88 | tag_to_data: HashSet<(String, TagData)>, 89 | ) -> HashMap { 90 | let mut ignored_tags_map: HashMap = HashMap::new(); 91 | for (tag, tag_data) in tag_to_data { 92 | let mut class_split = tag.split('.'); 93 | let tag = class_split.next().unwrap_or_else(|| { 94 | log::error!("Invalid css selector - must be of the format TAG#ID.CLASS, {tag}"); 95 | "" 96 | }); 97 | let classes = class_split.collect::>(); 98 | let mut id_split = tag.split('#'); 99 | let tag = id_split.next().unwrap_or_else(|| { 100 | log::error!("Invalid css selector - must be of the format TAG#ID.CLASS, {tag}"); 101 | "" 102 | }); 103 | let id = id_split.next(); 104 | let tag_identifier = ignored_tags_map.entry(tag.to_string()).or_default(); 105 | if classes.is_empty() && id.is_none() { 106 | tag_identifier.data = tag_data.clone(); 107 | } 108 | for class in classes { 109 | tag_identifier 110 | .classes 111 | .insert(class.to_string(), tag_data.clone()); 112 | } 113 | if let Some(id) = id { 114 | tag_identifier.ids.insert(id.to_string(), tag_data.clone()); 115 | } 116 | } 117 | ignored_tags_map 118 | } 119 | 120 | pub fn is_element_ignored(&self, element: &ElementRef) -> bool { 121 | let tag = element.value().name(); 122 | let tag_identifier = self.ignored_tags.get(tag); 123 | let general_identifier = self.ignored_tags.get(""); 124 | Self::matches_tag(element, tag_identifier) || Self::matches_tag(element, general_identifier) 125 | } 126 | 127 | fn matches_tag(element: &ElementRef, tag_identifier: Option<&TagIdentifier>) -> bool { 128 | match tag_identifier { 129 | Some(tag_identifier) => { 130 | tag_identifier.unconditional 131 | || Self::element_contains_class(element, tag_identifier) 132 | || Self::element_contains_id(element, tag_identifier) 133 | } 134 | None => false, 135 | } 136 | } 137 | 138 | fn element_contains_id(element: &ElementRef, tag_identifier: &TagIdentifier) -> bool { 139 | element 140 | .value() 141 | .id() 142 | .is_some_and(|id| tag_identifier.ids.contains(id)) 143 | } 144 | 145 | fn element_contains_class(element: &ElementRef, tag_identifier: &TagIdentifier) -> bool { 146 | element 147 | .value() 148 | .attr("class") 149 | .into_iter() 150 | .flat_map(|class_attr| class_attr.split_whitespace()) 151 | .any(|class| tag_identifier.classes.contains(class)) 152 | } 153 | 154 | pub fn is_block_element(&self, element: &ElementRef) -> bool { 155 | let tag_identifier = self.block_elements.get(element.value().name()); 156 | let general_identifier = self.block_elements.get(""); 157 | Self::matches_tag(element, tag_identifier) || Self::matches_tag(element, general_identifier) 158 | } 159 | 160 | pub fn is_indent_element(&self, element: &ElementRef) -> bool { 161 | let tag_identifier = self.indent_elements.get(element.value().name()); 162 | let general_identifier = self.indent_elements.get(""); 163 | Self::matches_tag(element, tag_identifier) || Self::matches_tag(element, general_identifier) 164 | } 165 | 166 | pub fn style_for_tag(&self, element: &ElementRef) -> Option