├── .gitignore ├── src ├── note │ ├── skim_item │ │ └── preview │ │ │ ├── mod.rs │ │ │ ├── structure.rs │ │ │ └── details.rs │ ├── random.rs │ ├── reachable.rs │ ├── skim_item.rs │ ├── links_term_tree.rs │ ├── mod.rs │ └── task_items_term_tree.rs ├── skim │ ├── mod.rs │ ├── open.rs │ ├── checkmark.rs │ ├── surf.rs │ └── stack_sequential.rs ├── print.rs ├── commands │ ├── init_db.rs │ ├── mod.rs │ ├── debug_cfg.rs │ ├── select.rs │ ├── create.rs │ ├── print.rs │ ├── rename.rs │ ├── remove.rs │ ├── unlink.rs │ ├── link.rs │ ├── surf.rs │ ├── checkmark.rs │ ├── stack.rs │ └── explore.rs ├── config │ ├── keymap.rs │ ├── keymap │ │ ├── surf.rs │ │ ├── checkmark.rs │ │ ├── stack.rs │ │ ├── single_key.rs │ │ └── explore.rs │ ├── color │ │ └── config_color.rs │ ├── external_commands.rs │ ├── color.rs │ ├── surf_parsing.rs │ ├── external_commands │ │ └── cmd_template.rs │ ├── mod.rs │ └── macros.rs ├── lines.rs ├── database │ └── mod.rs ├── external_commands.rs ├── highlight.rs ├── link │ ├── skim_item.rs │ └── parse.rs ├── task_item │ └── skim_item.rs ├── task_item.rs └── main.rs ├── scripts └── watch_mdtree ├── migrations ├── 20230122174026_notes.sql ├── 20230128213006_linkx.sql └── 20230422140650_stacks.sql ├── CONTRIBUTING ├── justfile ├── install_dependencies.sh ├── .github └── workflows │ ├── lint.yml │ └── ci.yml ├── INSTALLATION.md ├── LICENSE ├── Cargo.toml ├── README.md ├── cliff.toml ├── config.kdl ├── USAGE.md ├── CHANGELOG.md └── KEYBINDINGS.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | __default_home_path__ 3 | -------------------------------------------------------------------------------- /src/note/skim_item/preview/mod.rs: -------------------------------------------------------------------------------- 1 | mod details; 2 | mod structure; 3 | -------------------------------------------------------------------------------- /scripts/watch_mdtree: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | NOTE="$(mds -c select)" 6 | watch -c -x mds -c p -n "$NOTE" 7 | -------------------------------------------------------------------------------- /src/skim/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod checkmark; 2 | pub mod explore; 3 | pub mod open; 4 | pub mod stack_sequential; 5 | pub mod surf; 6 | -------------------------------------------------------------------------------- /migrations/20230122174026_notes.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | -- Add migration script here 3 | create table if not exists notes ( 4 | name text primary key, 5 | filename text, 6 | 7 | unique(filename) 8 | ); 9 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Unless you explicitly state otherwise, any contribution intentionally 5 | submitted for inclusion in the work by you shall be licensed as in 6 | LICENSE, without any additional terms or conditions. 7 | -------------------------------------------------------------------------------- /src/print.rs: -------------------------------------------------------------------------------- 1 | use colored::Colorize; 2 | 3 | pub fn format_two_tokens(tok_1: &str, tok_2: &str) -> String { 4 | format!( 5 | "{} {}", 6 | tok_1.truecolor(0, 255, 255), 7 | tok_2.truecolor(255, 0, 255) 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/note/random.rs: -------------------------------------------------------------------------------- 1 | use rand::{distributions::Alphanumeric, Rng}; // 0.8 2 | 3 | pub fn rand_suffix() -> String { 4 | rand::thread_rng() 5 | .sample_iter(&Alphanumeric) 6 | .take(7) 7 | .map(char::from) 8 | .collect() 9 | } 10 | -------------------------------------------------------------------------------- /src/commands/init_db.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use colored::Colorize; 4 | 5 | use crate::database::Sqlite; 6 | 7 | pub(crate) async fn exec(dir: PathBuf) -> Result { 8 | Sqlite::new(true, dir).await?; 9 | Ok("initialized db".truecolor(0, 255, 255).to_string()) 10 | } 11 | -------------------------------------------------------------------------------- /src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod debug_cfg; 2 | pub mod init_db; 3 | 4 | pub mod create; 5 | pub mod remove; 6 | pub mod rename; 7 | 8 | pub mod link; 9 | pub mod unlink; 10 | 11 | pub mod explore; 12 | pub mod surf; 13 | 14 | pub mod print; 15 | pub mod select; 16 | 17 | pub mod checkmark; 18 | pub mod stack; 19 | -------------------------------------------------------------------------------- /src/commands/debug_cfg.rs: -------------------------------------------------------------------------------- 1 | use crate::{config::Config, print::format_two_tokens}; 2 | use colored::Colorize; 3 | 4 | pub(crate) fn exec(config: Config) -> Result { 5 | let prefix = format!("{:#?}", config).truecolor(255, 255, 0).to_string(); 6 | let suffix = format_two_tokens("config", "valid"); 7 | Ok(format!("{}\n\n{}", prefix, suffix)) 8 | } 9 | -------------------------------------------------------------------------------- /migrations/20230128213006_linkx.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | 3 | CREATE TABLE linkx ( 4 | _from text not null, 5 | _to text not null, 6 | PRIMARY KEY (_from, _to), 7 | 8 | FOREIGN KEY(_from) REFERENCES notes(name) on delete cascade on update cascade, 9 | FOREIGN KEY(_to) REFERENCES notes(name) on delete cascade on update cascade ); 10 | CREATE INDEX _to_index ON linkx(_to); -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | bacon: 2 | bacon check 3 | 4 | 5 | fmt: 6 | cargo fmt --all 7 | 8 | dev_install: 9 | cargo install --path . 10 | 11 | cargo-watch-test: 12 | cargo watch -x 'test -- --nocapture' 13 | 14 | shell: 15 | zsh 16 | 17 | ridge: 18 | skridge 19 | 20 | cargo-check: 21 | cargo check --all 22 | 23 | cargo-test: 24 | cargo test --all -- --nocapture 25 | 26 | cargo-fmt-check: 27 | cargo fmt --all --check 28 | 29 | cargo-clippy-check: 30 | cargo clippy --all 31 | 32 | pre-push-check: cargo-check cargo-fmt-check cargo-clippy-check cargo-test 33 | -------------------------------------------------------------------------------- /migrations/20230422140650_stacks.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | 3 | create table if not exists stack_tags ( 4 | tag text primary key 5 | ); 6 | 7 | insert into stack_tags(tag) values ("GLOBAL"); 8 | 9 | create table if not exists stacked_notes ( 10 | stack_tag text not null, 11 | stack_index integer not null, 12 | note text not null, 13 | 14 | PRIMARY KEY (stack_tag, stack_index), 15 | unique (stack_tag, note), 16 | 17 | FOREIGN KEY(stack_tag) REFERENCES stack_tags(tag) on delete cascade on update cascade, 18 | FOREIGN KEY(note) REFERENCES notes(name) on delete cascade on update cascade ); 19 | -------------------------------------------------------------------------------- /src/config/keymap.rs: -------------------------------------------------------------------------------- 1 | use crate::{config::KdlNodeErrorType, impl_try_from_kdl_node}; 2 | use std::collections::HashMap; 3 | 4 | use kdl::KdlNode; 5 | 6 | use self::{ 7 | checkmark::CheckmarkKeymap, explore::ExploreKeymap, stack::StackKeymap, surf::SurfKeymap, 8 | }; 9 | pub mod single_key; 10 | 11 | pub mod checkmark; 12 | pub mod explore; 13 | pub mod stack; 14 | pub mod surf; 15 | 16 | #[derive(Debug, Clone)] 17 | pub struct Keymap { 18 | pub surf: SurfKeymap, 19 | pub checkmark: CheckmarkKeymap, 20 | pub stack: StackKeymap, 21 | pub explore: ExploreKeymap, 22 | } 23 | 24 | impl_try_from_kdl_node!(Keymap, "world.keymap", surf, checkmark, stack, explore); 25 | -------------------------------------------------------------------------------- /install_dependencies.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # $DIR preview 6 | cargo install --locked exa 7 | # $FILE preview 8 | cargo install --locked bat 9 | 10 | rm -rf /tmp/helix 11 | pushd /tmp 12 | git clone https://github.com/helix-editor/helix 13 | pushd helix 14 | # $FILE open with helix editor `hx` 15 | cargo install --locked --path helix-term 16 | 17 | popd; 18 | popd; 19 | rm -rf /tmp/helix 20 | 21 | # these two are used for opening $DIR in default config 22 | cargo install --locked zellij 23 | cargo install --locked broot 24 | 25 | # this is where `wl-copy` from default config is found for piping $SNIPPET_TEXT into it 26 | cargo install --locked wl-clipboard-rs-tools 27 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | workflow_call: 5 | 6 | env: 7 | CARGO_TERM_COLOR: always 8 | 9 | jobs: 10 | fmt: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Run rustfmt 15 | run: cargo fmt --all --check 16 | 17 | clippy: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: actions/cache@v3 22 | with: 23 | path: | 24 | ~/.cargo/bin/ 25 | ~/.cargo/registry/index/ 26 | ~/.cargo/registry/cache/ 27 | ~/.cargo/git/db/ 28 | target/ 29 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 30 | - uses: giraffate/clippy-action@v1 31 | -------------------------------------------------------------------------------- /INSTALLATION.md: -------------------------------------------------------------------------------- 1 | # Install 2 | 3 | 0. This app doesn't build on Windows, unfortunately. It's so due to [tuikit/skim not buildings on Windows](https://github.com/lotabout/tuikit/issues/13) 4 | 5 | 1. Install the binary itself. 6 | 7 | ``` 8 | cargo install --locked mds 9 | ``` 10 | 11 | 2. Install external commands used in default config, besides `firefox`, which is the default browser for opening links, 12 | by running [install_dependencies.sh](./install_dependencies.sh) 13 | ``` 14 | wget -O - https://raw.githubusercontent.com/dj8yfo/mds/master/install_dependencies.sh | bash 15 | ``` 16 | 3. Create config at `$HOME/.config/mds/config.kdl` with [content](./config.kdl). 17 | - Edit the folder, where you'd like to put notes on your system. (Replace `/home/user/notes` default value) 18 | 4. Check your config got correctly fetched up. 19 | ``` 20 | mds debug-cfg 21 | ``` 22 | 5. Initialize .sqlite database in your notes folder with 23 | ``` 24 | mds init 25 | ``` 26 | -------------------------------------------------------------------------------- /src/commands/select.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config::{color::ColorScheme, ExternalCommands, SurfParsing}, 3 | database::{Database, SqliteAsyncHandle}, 4 | highlight::MarkdownStatic, 5 | note::PreviewType, 6 | }; 7 | 8 | pub(crate) async fn exec( 9 | db: SqliteAsyncHandle, 10 | external_commands: ExternalCommands, 11 | surf_parsing: SurfParsing, 12 | md_static: MarkdownStatic, 13 | color_scheme: ColorScheme, 14 | ) -> Result { 15 | let list = db.lock().await.list(md_static, color_scheme).await?; 16 | let straight = true; 17 | let multi = false; 18 | let nested_threshold = 1; 19 | let note = crate::skim::open::Iteration::new( 20 | "select".to_string(), 21 | list, 22 | db.clone(), 23 | multi, 24 | PreviewType::Details, 25 | external_commands.clone(), 26 | surf_parsing, 27 | md_static, 28 | color_scheme, 29 | straight, 30 | nested_threshold, 31 | ) 32 | .run() 33 | .await?; 34 | 35 | Ok(note.name()) 36 | } 37 | -------------------------------------------------------------------------------- /src/config/keymap/surf.rs: -------------------------------------------------------------------------------- 1 | use kdl::KdlNode; 2 | 3 | use super::single_key::SingleKey; 4 | use crate::config::KdlNodeErrorType; 5 | use crate::{impl_from_self_into_action_hashmap, impl_try_from_kdl_node_uniqueness_check}; 6 | use std::collections::{HashMap, HashSet}; 7 | 8 | #[derive(Debug, Clone)] 9 | pub struct SurfKeymap { 10 | pub open_xdg: SingleKey, 11 | pub jump_to_link_or_snippet: SingleKey, 12 | pub return_to_explore: SingleKey, 13 | } 14 | 15 | #[derive(Debug, Clone, Eq, PartialEq, Hash)] 16 | pub enum Action { 17 | OpenXDG, 18 | JumpToLinkOrSnippet, 19 | ReturnToExplore, 20 | } 21 | 22 | impl_try_from_kdl_node_uniqueness_check!( 23 | SurfKeymap, 24 | "world.keymap.surf", 25 | open_xdg, 26 | jump_to_link_or_snippet, 27 | return_to_explore 28 | ); 29 | 30 | impl_from_self_into_action_hashmap!(SurfKeymap, Action, 31 | Action::OpenXDG => open_xdg | "accept".to_string(), 32 | Action::JumpToLinkOrSnippet => jump_to_link_or_snippet | "accept".to_string(), 33 | Action::ReturnToExplore => return_to_explore | "accept".to_string() 34 | ); 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2023 Scott Chacon and others 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/commands/create.rs: -------------------------------------------------------------------------------- 1 | use syntect::easy::HighlightLines; 2 | 3 | use crate::{ 4 | config::color::ColorScheme, 5 | database::{Database, SqliteAsyncHandle}, 6 | highlight::MarkdownStatic, 7 | note::Note, 8 | print::format_two_tokens, 9 | }; 10 | 11 | pub(crate) async fn exec( 12 | title: &str, 13 | db: SqliteAsyncHandle, 14 | 15 | is_tag: bool, 16 | 17 | md_static: MarkdownStatic, 18 | 19 | color_scheme: ColorScheme, 20 | ) -> Result { 21 | let note = create(title, db, is_tag, md_static, color_scheme).await?; 22 | 23 | Ok(format_two_tokens("note created", &format!("{:?}", note))) 24 | // Err(anyhow::anyhow!("baby futter")) 25 | } 26 | 27 | pub(crate) async fn create( 28 | title: &str, 29 | db: SqliteAsyncHandle, 30 | 31 | is_tag: bool, 32 | 33 | md_static: MarkdownStatic, 34 | 35 | color_scheme: ColorScheme, 36 | ) -> Result { 37 | let mut highlighter = HighlightLines::new(md_static.1, md_static.2); 38 | let note = Note::init( 39 | title.to_string(), 40 | is_tag, 41 | &mut highlighter, 42 | md_static, 43 | color_scheme, 44 | ); 45 | 46 | db.lock().await.save(¬e).await?; 47 | note.persist()?; 48 | Ok(note) 49 | } 50 | -------------------------------------------------------------------------------- /src/config/color/config_color.rs: -------------------------------------------------------------------------------- 1 | use kdl::KdlNode; 2 | use rgb::RGB8; 3 | 4 | use crate::config::KdlNodeErrorType; 5 | #[derive(Debug, Clone, Copy)] 6 | pub struct ConfigRGB(pub RGB8); 7 | 8 | impl TryFrom<&KdlNode> for ConfigRGB { 9 | type Error = miette::Report; 10 | 11 | fn try_from(value: &KdlNode) -> Result { 12 | let string = value 13 | .get(0) 14 | .ok_or(KdlNodeErrorType { 15 | err_span: *value.span(), 16 | description: "node's first argument not found".to_string(), 17 | }) 18 | .map_err(Into::::into)? 19 | .value() 20 | .as_string() 21 | .ok_or(KdlNodeErrorType { 22 | err_span: *value.span(), 23 | description: "argument's value is expected to be of string type".to_string(), 24 | }) 25 | .map_err(Into::::into)? 26 | .to_string(); 27 | 28 | let color = serde_json::from_str(&string).map_err(|err| { 29 | let err = KdlNodeErrorType { 30 | err_span: *value.span(), 31 | description: format!("RGB8 deserialization from json problem {}", err), 32 | }; 33 | Into::::into(err) 34 | })?; 35 | 36 | Ok(Self(color)) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/config/external_commands.rs: -------------------------------------------------------------------------------- 1 | use super::KdlNodeErrorType; 2 | use crate::impl_try_from_kdl_node_tagged; 3 | 4 | use self::cmd_template::CmdTemplate; 5 | pub mod cmd_template; 6 | use kdl::KdlNode; 7 | use std::collections::HashMap; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct ExternalCommands { 11 | pub preview: Preview, 12 | pub open: Open, 13 | } 14 | 15 | impl_try_from_kdl_node_tagged!(ExternalCommands, "world.external-commands", 16 | "preview" => preview, 17 | "open" => open); 18 | 19 | #[derive(Debug, Clone)] 20 | pub struct Preview { 21 | pub dir_cmd: CmdTemplate, 22 | pub file_cmd: CmdTemplate, 23 | pub file_line_cmd: CmdTemplate, 24 | } 25 | 26 | #[derive(Debug, Clone)] 27 | pub struct Open { 28 | pub file_cmd: CmdTemplate, 29 | pub file_jump_cmd: CmdTemplate, 30 | pub url_cmd: CmdTemplate, 31 | pub dir_cmd: CmdTemplate, 32 | pub pipe_text_snippet_cmd: CmdTemplate, 33 | } 34 | 35 | impl_try_from_kdl_node_tagged!(Preview, "world.external-commands.preview", 36 | "dir" => dir_cmd, 37 | "file" => file_cmd, 38 | "file-line" => file_line_cmd 39 | ); 40 | 41 | impl_try_from_kdl_node_tagged!(Open, "world.external-commands.open", 42 | "file" => file_cmd, 43 | "file-jump" => file_jump_cmd, 44 | "dir" => dir_cmd, 45 | "url" => url_cmd, 46 | "pipe-$SNIPPET_TEXT-into" => pipe_text_snippet_cmd 47 | ); 48 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mds" 3 | version = "0.19.2" 4 | author = ["dj8yf0μl"] 5 | description = "A skim-based `*.md` explore and surf note-taking tool" 6 | repository = "https://github.com/dj8yfo/meudeus" 7 | homepage = "https://youtu.be/z4DFN72QVSw" 8 | keywords = ["note-taking", "PKM", "cli", "skim", "markdown"] 9 | readme = "README.md" 10 | 11 | exclude = ["tutorial.gif"] 12 | 13 | edition = "2021" 14 | 15 | license = "MIT" 16 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 17 | 18 | [dependencies] 19 | anyhow = "1.0.68" 20 | clap = {version = "4.1.1", features=["cargo"]} 21 | colored = "2.0.0" 22 | chrono = "0.4.19" 23 | 24 | sql-builder = "3" 25 | sqlx = { version = "0.6", features = [ 26 | "runtime-tokio-rustls", 27 | "chrono", 28 | "sqlite", 29 | ] } 30 | 31 | skim = "0.10.4" 32 | async-trait = "0.1.58" 33 | log = "0.4.17" 34 | tokio = { version = "1", features = ["full"] } 35 | rand = "0.8.5" 36 | async-std = "1.12.0" 37 | comfy-table = "6.1.4" 38 | 39 | regex = "1.7.1" 40 | duct = "0.13" 41 | 42 | xdg = "2.4.1" 43 | kdl = "4.6.0" 44 | inquire = "0.5.3" 45 | comrak = "0.18.0" 46 | syntect = "5.0.0" 47 | 48 | bidir_termtree = "0.1.1" 49 | async-recursion = "1.0.2" 50 | futures = "0.3.28" 51 | 52 | env_logger = "0.10.0" 53 | 54 | serde_json = "1.0.96" 55 | rgb = { version = "0.8", features = ["serde"] } 56 | env-substitute = "0.1.0" 57 | opener = "0.6.1" 58 | 59 | lazy_static = "1.4.0" 60 | tuikit = "0.5.0" 61 | miette = { version = "5.9.0", features = ["fancy"] } 62 | thiserror = "1.0.40" 63 | -------------------------------------------------------------------------------- /src/config/keymap/checkmark.rs: -------------------------------------------------------------------------------- 1 | use crate::config::KdlNodeErrorType; 2 | use kdl::KdlNode; 3 | 4 | use crate::{impl_from_self_into_action_hashmap, impl_try_from_kdl_node_uniqueness_check}; 5 | use std::collections::{HashMap, HashSet}; 6 | 7 | use super::single_key::SingleKey; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct CheckmarkKeymap { 11 | pub jump_to_task: SingleKey, 12 | pub copy_task_subtree_into_clipboard: SingleKey, 13 | pub widen_context_to_all_tasks: SingleKey, 14 | pub narrow_context_to_selected_task_subtree: SingleKey, 15 | pub return_to_explore: SingleKey, 16 | } 17 | 18 | #[derive(Debug, Clone, Eq, PartialEq, Hash)] 19 | pub enum Action { 20 | JumpToTask, 21 | CopyTaskSubtree, 22 | WidenContext, 23 | NarrowContext, 24 | ReturnToExplore, 25 | } 26 | 27 | impl_try_from_kdl_node_uniqueness_check!( 28 | CheckmarkKeymap, 29 | "world.keymap.checkmark", 30 | jump_to_task, 31 | copy_task_subtree_into_clipboard, 32 | widen_context_to_all_tasks, 33 | narrow_context_to_selected_task_subtree, 34 | return_to_explore 35 | ); 36 | 37 | impl_from_self_into_action_hashmap!(CheckmarkKeymap, Action, 38 | Action::JumpToTask => jump_to_task | "accept".to_string(), 39 | Action::CopyTaskSubtree => copy_task_subtree_into_clipboard | "accept".to_string(), 40 | Action::WidenContext => widen_context_to_all_tasks | "accept".to_string(), 41 | Action::NarrowContext => narrow_context_to_selected_task_subtree | "accept".to_string(), 42 | Action::ReturnToExplore => return_to_explore | "accept".to_string() 43 | ); 44 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master, "**-dev"] 6 | paths: ["**/*.rs", "**/*.yml", "**/*.toml", "**/*.lock"] 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | env: 11 | CARGO_TERM_COLOR: always 12 | 13 | jobs: 14 | Lint: 15 | uses: dj8yfo/meudeus/.github/workflows/lint.yml@master 16 | 17 | Test: 18 | needs: Lint 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | os: [macos-latest, ubuntu-latest] 23 | runs-on: ${{ matrix.os }} 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: actions/cache@v3 27 | with: 28 | path: | 29 | ~/.cargo/bin/ 30 | ~/.cargo/registry/index/ 31 | ~/.cargo/registry/cache/ 32 | ~/.cargo/git/db/ 33 | target/ 34 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 35 | - name: Run test 36 | env: 37 | RUST_BACKTRACE: full 38 | run: | 39 | cargo test 40 | 41 | 42 | Build: 43 | needs: test 44 | strategy: 45 | fail-fast: false 46 | matrix: 47 | os: [macOS-latest, ubuntu-latest] 48 | runs-on: ${{ matrix.os }} 49 | steps: 50 | - uses: actions/checkout@v3 51 | - uses: actions/cache@v3 52 | with: 53 | path: | 54 | ~/.cargo/bin/ 55 | ~/.cargo/registry/index/ 56 | ~/.cargo/registry/cache/ 57 | ~/.cargo/git/db/ 58 | target/ 59 | key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 60 | - name: Build 61 | run: cargo build --verbose 62 | -------------------------------------------------------------------------------- /src/note/reachable.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use sqlx::Result as SqlxResult; 4 | 5 | use crate::{ 6 | config::color::ColorScheme, 7 | database::{Database, SqliteAsyncHandle}, 8 | highlight::MarkdownStatic, 9 | }; 10 | 11 | use super::Note; 12 | 13 | impl super::Note { 14 | pub async fn reachable_notes( 15 | &self, 16 | db: SqliteAsyncHandle, 17 | md_static: MarkdownStatic, 18 | color_scheme: ColorScheme, 19 | straight: bool, 20 | include_self: bool, 21 | ) -> SqlxResult> { 22 | let mut reachable_all: HashSet = HashSet::new(); 23 | let mut current_layer: HashSet = HashSet::new(); 24 | current_layer.insert(self.clone()); 25 | 26 | loop { 27 | let mut next_layer: HashSet = HashSet::new(); 28 | 29 | let lock = db.lock().await; 30 | for note in ¤t_layer { 31 | let forward_links = lock 32 | .find_links_from(¬e.name(), md_static, color_scheme, straight) 33 | .await?; 34 | next_layer.extend(forward_links); 35 | } 36 | reachable_all.extend(current_layer.drain()); 37 | let diff: HashSet<_> = next_layer.difference(&reachable_all).collect(); 38 | if diff.is_empty() { 39 | break; 40 | } 41 | 42 | current_layer = next_layer; 43 | } 44 | if !include_self { 45 | reachable_all.remove(self); 46 | } 47 | let all_vec: Vec<_> = reachable_all.into_iter().collect(); 48 | Ok(all_vec) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/config/color.rs: -------------------------------------------------------------------------------- 1 | use super::KdlNodeErrorType; 2 | use crate::impl_try_from_kdl_node_tagged; 3 | use kdl::KdlNode; 4 | use std::collections::HashMap; 5 | 6 | use super::ConfigPath; 7 | 8 | use config_color::ConfigRGB; 9 | 10 | mod config_color; 11 | 12 | #[derive(Debug, Clone)] 13 | pub struct Color { 14 | pub theme: ConfigPath, 15 | pub elements: ColorScheme, 16 | } 17 | 18 | #[derive(Debug, Clone, Copy)] 19 | pub struct ColorScheme { 20 | pub links: Links, 21 | pub notes: Notes, 22 | } 23 | 24 | #[derive(Debug, Clone, Copy)] 25 | pub struct Notes { 26 | pub tag: ConfigRGB, 27 | pub special_tag: ConfigRGB, 28 | } 29 | 30 | #[derive(Debug, Clone, Copy)] 31 | pub struct Links { 32 | pub parent_name: ConfigRGB, 33 | pub url: ConfigRGB, 34 | pub file: ConfigRGB, 35 | pub dir: ConfigRGB, 36 | pub broken: ConfigRGB, 37 | pub code_block: ConfigRGB, 38 | pub unlisted: ConfigRGB, 39 | pub cycle: ConfigRGB, 40 | } 41 | 42 | impl_try_from_kdl_node_tagged!(Color, "world.color", 43 | "theme" => theme, 44 | "elements" => elements 45 | ); 46 | 47 | impl_try_from_kdl_node_tagged!(ColorScheme, "world.color.elements", 48 | "links" => links, 49 | "notes" => notes 50 | ); 51 | 52 | impl_try_from_kdl_node_tagged!(Notes, "world.color.elements.notes", 53 | "tag" => tag, 54 | "special_tag" => special_tag 55 | ); 56 | 57 | impl_try_from_kdl_node_tagged!(Links, "world.color.elements.links", 58 | "parent_name" => parent_name, 59 | "url" => url, 60 | "file" => file, 61 | "dir" => dir, 62 | "broken" => broken, 63 | "code_block" => code_block, 64 | "unlisted" => unlisted, 65 | "cycle" => cycle 66 | ); 67 | -------------------------------------------------------------------------------- /src/lines.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone, Copy)] 2 | pub struct EditorPosition { 3 | pub line: usize, 4 | pub column: usize, 5 | } 6 | 7 | pub fn find_position(initial_contents: &str, byte_offset: usize) -> EditorPosition { 8 | let mut contents: &str = initial_contents; 9 | let mut vec_lines = vec![]; 10 | // line index, first byte index 11 | vec_lines.push((0, 0)); 12 | let contents_len = contents.len(); 13 | let mut newline_char_offset = contents.find('\n').unwrap_or(contents_len); 14 | let (line, line_start) = if newline_char_offset == contents_len { 15 | // we are on last line 16 | let last = vec_lines.last().unwrap(); 17 | (last.0 + 1, last.1) 18 | } else { 19 | while byte_offset >= vec_lines.last().unwrap().1 { 20 | let next_index = vec_lines.last().unwrap().0 + 1; 21 | let next_line_offset = vec_lines.last().unwrap().1 + newline_char_offset + 1; 22 | if next_line_offset >= initial_contents.len() { 23 | break; 24 | } 25 | vec_lines.push((next_index, next_line_offset)); 26 | contents = &initial_contents[next_line_offset..]; 27 | newline_char_offset = contents.find('\n').unwrap_or(contents.len()); 28 | } 29 | if byte_offset >= vec_lines.last().unwrap().1 { 30 | let last = vec_lines.last().unwrap(); 31 | (last.0 + 1, last.1) 32 | } else { 33 | let prev_before_last = vec_lines[vec_lines.len() - 2]; 34 | (prev_before_last.0 + 1, prev_before_last.1) 35 | } 36 | }; 37 | 38 | let count = initial_contents[line_start..byte_offset].chars().count(); 39 | let column = count + 1; 40 | 41 | EditorPosition { line, column } 42 | } 43 | -------------------------------------------------------------------------------- /src/config/keymap/stack.rs: -------------------------------------------------------------------------------- 1 | use crate::config::KdlNodeErrorType; 2 | use kdl::KdlNode; 3 | use std::collections::{HashMap, HashSet}; 4 | 5 | use crate::{impl_from_self_into_action_hashmap, impl_try_from_kdl_node_uniqueness_check}; 6 | 7 | use super::single_key::SingleKey; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct StackKeymap { 11 | pub toggle_preview_type: SingleKey, 12 | pub pop_note_from_stack: SingleKey, 13 | pub move_note_to_top_of_stack: SingleKey, 14 | pub return_to_explore: SingleKey, 15 | pub swap_with_above: SingleKey, 16 | pub swap_with_below: SingleKey, 17 | pub deselect_all: SingleKey, 18 | } 19 | 20 | #[derive(Debug, Clone, Eq, PartialEq, Hash)] 21 | pub enum Action { 22 | TogglePreviewType, 23 | PopNoteFromStack, 24 | MoveNoteToStackTop, 25 | ReturnToExplore, 26 | SwapWithAbove, 27 | SwapWithBelow, 28 | DeselectAll, 29 | } 30 | 31 | impl_try_from_kdl_node_uniqueness_check!( 32 | StackKeymap, 33 | "world.keymap.stack", 34 | toggle_preview_type, 35 | pop_note_from_stack, 36 | move_note_to_top_of_stack, 37 | return_to_explore, 38 | swap_with_above, 39 | swap_with_below, 40 | deselect_all 41 | ); 42 | 43 | impl_from_self_into_action_hashmap!(StackKeymap, Action, 44 | Action::TogglePreviewType => toggle_preview_type | "accept".to_string(), 45 | Action::PopNoteFromStack => pop_note_from_stack | "accept".to_string(), 46 | Action::MoveNoteToStackTop => move_note_to_top_of_stack | "accept".to_string(), 47 | Action::ReturnToExplore => return_to_explore | "accept".to_string(), 48 | Action::SwapWithAbove => swap_with_above | "accept".to_string(), 49 | Action::SwapWithBelow => swap_with_below | "accept".to_string(), 50 | Action::DeselectAll => deselect_all | "deselect-all".to_string() 51 | 52 | ); 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Crates.io 4 | 5 | 6 | Build & Test 7 | 8 |

9 | 10 | # Sample screencast 11 | 12 | [![asciicast](https://asciinema.org/a/QtWI1lMVbQ52LMP7Yf6u7QvAA.svg)](https://asciinema.org/a/QtWI1lMVbQ52LMP7Yf6u7QvAA) 13 | 14 | # [Installation](./INSTALLATION.md) 15 | 16 | # About 17 | 18 | `mds` is a cli tool for 19 | 1. navigating a collection of markdown notes 20 | 2. creating new notes and linking them together. Notes' metadata and inter-note links are stored outside of them in .sqlite database. 21 | 3. opening `[description](links)` found inside of markdown notes 22 | 4. jumping to these `[description](links)`' location in markdown in editor (if one needs to change them) 23 | 5. etc. 24 | 25 | It links to external tools, such as `bat` via [config](./config.kdl). 26 | 27 | `mds` works with any *dumb* editor. It doesn't require editor to have any kind of rich plugin system. 28 | 29 | # [Usage](./USAGE.md) 30 | 31 | # [Keybindings](./KEYBINDINGS.md) 32 | 33 | 34 | 35 | # Colors 36 | 37 | - Some color themes for markdown elements in `world.color.theme` field of [config](./config.kdl) can be found at [rainglow/sublime](https://github.com/rainglow/sublime) 38 | and previewed at this awesome website [rainglow.io](https://rainglow.io/preview/). 39 | - if the patchwork in markdown irritates you, please remember, that `settings.background` value in a theme 40 | is editable 41 | - `world.color.elements` field of [config](./config.kdl) specifies colors of most of other displayed objects. 42 | 43 | # [Changelog](./CHANGELOG.md) 44 | -------------------------------------------------------------------------------- /src/config/surf_parsing.rs: -------------------------------------------------------------------------------- 1 | use kdl::KdlNode; 2 | use regex::Regex; 3 | use std::collections::HashMap; 4 | 5 | use crate::impl_try_from_kdl_node_tagged; 6 | 7 | use super::KdlNodeErrorType; 8 | #[derive(Debug, Clone)] 9 | pub struct SurfParsing { 10 | pub url_regex: ConfigRegex, 11 | pub markdown_reference_link_regex: ConfigRegex, 12 | pub task_item_regex: ConfigRegex, 13 | pub has_line_regex: ConfigRegex, 14 | } 15 | impl_try_from_kdl_node_tagged!(SurfParsing, "world.surf-parsing", 16 | "markdown-reference-link-regex" => markdown_reference_link_regex, 17 | "url-regex" => url_regex, 18 | "file-dest-has-line-regex" => has_line_regex, 19 | "task-item-regex" => task_item_regex); 20 | 21 | #[derive(Debug, Clone)] 22 | pub struct ConfigRegex(pub Regex); 23 | 24 | impl TryFrom<&KdlNode> for ConfigRegex { 25 | type Error = miette::Report; 26 | 27 | fn try_from(value: &KdlNode) -> Result { 28 | let string = value 29 | .get(0) 30 | .ok_or(KdlNodeErrorType { 31 | err_span: *value.span(), 32 | description: "node's first argument not found".to_string(), 33 | }) 34 | .map_err(Into::::into)? 35 | .value() 36 | .as_string() 37 | .ok_or(KdlNodeErrorType { 38 | err_span: *value.span(), 39 | description: "argument's value is expected to be of string type".to_string(), 40 | }) 41 | .map_err(Into::::into)? 42 | .to_string(); 43 | 44 | let regex = Regex::new(&string).map_err(|err| { 45 | let err = KdlNodeErrorType { 46 | err_span: *value.span(), 47 | description: format!("{}", err), 48 | }; 49 | 50 | Into::::into(err) 51 | })?; 52 | 53 | Ok(Self(regex)) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/commands/print.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use crate::{ 4 | config::{color::ColorScheme, ExternalCommands, SurfParsing}, 5 | database::{Database, SqliteAsyncHandle}, 6 | highlight::MarkdownStatic, 7 | note::PreviewType, 8 | print::format_two_tokens, 9 | }; 10 | 11 | use colored::Colorize; 12 | 13 | pub(crate) async fn exec( 14 | db: SqliteAsyncHandle, 15 | external_commands: ExternalCommands, 16 | surf_parsing: SurfParsing, 17 | name: Option, 18 | md_static: MarkdownStatic, 19 | color_scheme: ColorScheme, 20 | ) -> Result { 21 | let nested_threshold = 1; 22 | let note = { 23 | if let Some(name) = name { 24 | db.lock().await.get(&name, md_static, color_scheme).await? 25 | } else { 26 | let list = db.lock().await.list(md_static, color_scheme).await?; 27 | let multi = false; 28 | crate::skim::open::Iteration::new( 29 | "print".to_string(), 30 | list, 31 | db.clone(), 32 | multi, 33 | PreviewType::Details, 34 | external_commands.clone(), 35 | surf_parsing.clone(), 36 | md_static, 37 | color_scheme, 38 | true, 39 | nested_threshold, 40 | ) 41 | .run() 42 | .await? 43 | } 44 | }; 45 | 46 | let (tree, _) = note 47 | .construct_link_term_tree( 48 | 0, 49 | nested_threshold, 50 | HashSet::new(), 51 | external_commands, 52 | surf_parsing, 53 | db, 54 | md_static, 55 | color_scheme, 56 | ) 57 | .await?; 58 | 59 | println!("{}", tree); 60 | 61 | eprintln!("{}", format_two_tokens("printed", ¬e.name())); 62 | Ok("success".truecolor(0, 255, 255).to_string()) 63 | } 64 | -------------------------------------------------------------------------------- /src/commands/rename.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config::{color::ColorScheme, ExternalCommands, SurfParsing}, 3 | database::{Database, SqliteAsyncHandle}, 4 | highlight::MarkdownStatic, 5 | note::{Note, PreviewType}, 6 | print::format_two_tokens, 7 | skim::open::Iteration, 8 | }; 9 | use colored::Colorize; 10 | use inquire::Text; 11 | use syntect::easy::HighlightLines; 12 | 13 | pub(crate) async fn exec( 14 | db: SqliteAsyncHandle, 15 | external_commands: ExternalCommands, 16 | surf_parsing: SurfParsing, 17 | 18 | md_static: MarkdownStatic, 19 | color_scheme: ColorScheme, 20 | ) -> Result { 21 | let list = db.lock().await.list(md_static, color_scheme).await?; 22 | 23 | let straight = true; 24 | let multi = false; 25 | let nested_threshold = 1; 26 | 27 | let note = Iteration::new( 28 | "rename".to_string(), 29 | list, 30 | db.clone(), 31 | multi, 32 | PreviewType::Details, 33 | external_commands.clone(), 34 | surf_parsing, 35 | md_static, 36 | color_scheme, 37 | straight, 38 | nested_threshold, 39 | ) 40 | .run() 41 | .await?; 42 | 43 | rename(note, db, md_static).await?; 44 | 45 | Ok("success".truecolor(0, 255, 255).to_string()) 46 | } 47 | 48 | pub(crate) async fn rename( 49 | mut note: Note, 50 | db: SqliteAsyncHandle, 51 | md_static: MarkdownStatic, 52 | ) -> Result { 53 | let new_name = Text::new("Enter new note's name:") 54 | .with_initial_value(¬e.name()) 55 | .prompt()?; 56 | 57 | let prev_name = note.name(); 58 | db.lock().await.rename_note(¬e, &new_name).await?; 59 | let mut highlighter = HighlightLines::new(md_static.1, md_static.2); 60 | note.rename(&new_name, &mut highlighter, md_static); 61 | 62 | eprintln!( 63 | "{}", 64 | format_two_tokens("renamed", &format!("{} -> {}", prev_name, new_name)) 65 | ); 66 | Ok(note) 67 | } 68 | -------------------------------------------------------------------------------- /src/database/mod.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use sqlx::Result; 3 | 4 | use crate::{config::color::ColorScheme, highlight::MarkdownStatic, note::Note}; 5 | 6 | mod sqlite; 7 | pub use sqlite::{Sqlite, SqliteAsyncHandle}; 8 | 9 | #[async_trait] 10 | pub trait Database: Send + Sync { 11 | async fn save(&mut self, note: &Note) -> Result<()>; 12 | async fn list(&self, md_static: MarkdownStatic, color_scheme: ColorScheme) 13 | -> Result>; 14 | async fn get( 15 | &self, 16 | name: &str, 17 | md_static: MarkdownStatic, 18 | color_scheme: ColorScheme, 19 | ) -> Result; 20 | async fn remove_note(&mut self, note: &Note) -> Result<()>; 21 | async fn rename_note(&mut self, note: &Note, new_name: &str) -> Result<()>; 22 | async fn insert_link(&mut self, from: &str, to: &str, straight: bool) -> Result<()>; 23 | async fn remove_link(&mut self, from: &str, to: &str, straight: bool) -> Result<()>; 24 | async fn find_links_from( 25 | &self, 26 | from: &str, 27 | md_static: MarkdownStatic, 28 | color_scheme: ColorScheme, 29 | straight: bool, 30 | ) -> Result>; 31 | async fn find_links_to( 32 | &self, 33 | to: &str, 34 | md_static: MarkdownStatic, 35 | color_scheme: ColorScheme, 36 | straight: bool, 37 | ) -> Result>; 38 | async fn push_note_to_stack(&mut self, stack: &str, note: &str) -> Result<()>; 39 | async fn select_from_stack( 40 | &mut self, 41 | stack: &str, 42 | md_static: MarkdownStatic, 43 | color_scheme: ColorScheme, 44 | ) -> Result>; 45 | async fn pop_note_from_stack(&mut self, stack: &str, note: &str) -> Result<()>; 46 | async fn move_to_topmost(&mut self, stack: &str, note: &str) -> Result<()>; 47 | async fn swap_with_above(&mut self, stack: &str, note: &str) -> Result<()>; 48 | async fn swap_with_below(&mut self, stack: &str, note: &str) -> Result<()>; 49 | } 50 | -------------------------------------------------------------------------------- /src/external_commands.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use colored::Colorize; 4 | use duct::cmd; 5 | 6 | use crate::config::CmdTemplate; 7 | 8 | pub fn fetch_content(mut file_cmd: CmdTemplate, file_path: Option<&PathBuf>) -> Option { 9 | if let Some(file_path) = file_path { 10 | file_cmd.replace_matching_element("$FILE", file_path.to_str().unwrap_or("bad utf path")); 11 | match cmd(file_cmd.command, file_cmd.args).read() { 12 | Ok(string) => Some(string), 13 | Err(err) => Some(format!("{:?}", err).red().to_string()), 14 | } 15 | } else { 16 | None 17 | } 18 | } 19 | 20 | pub fn fetch_content_range( 21 | mut file_line_cmd: CmdTemplate, 22 | file_path: Option<&PathBuf>, 23 | line: u64, 24 | ) -> Option { 25 | if let Some(file_path) = file_path { 26 | let first = if line - 20 > line { 27 | 1 28 | } else { 29 | std::cmp::max(1, line - 20) 30 | }; 31 | let last = line + 5; 32 | 33 | file_line_cmd 34 | .replace_in_matching_element("$FILE", file_path.to_str().unwrap_or("bad utf path")); 35 | file_line_cmd.replace_in_matching_element("$FIRST", &format!("{}", first)); 36 | file_line_cmd.replace_in_matching_element("$LAST", &format!("{}", last)); 37 | file_line_cmd.replace_in_matching_element("$LINE", &format!("{}", line)); 38 | match cmd(file_line_cmd.command, file_line_cmd.args).read() { 39 | Ok(string) => Some(string), 40 | Err(err) => Some(format!("{:?}", err).red().to_string()), 41 | } 42 | } else { 43 | None 44 | } 45 | } 46 | 47 | #[allow(clippy::ptr_arg)] 48 | pub fn list_dir(mut dir_cmd: CmdTemplate, dir: &PathBuf) -> String { 49 | dir_cmd.replace_matching_element("$DIR", dir.to_str().unwrap_or("bad utf path")); 50 | match cmd(dir_cmd.command, dir_cmd.args).read() { 51 | Ok(output) => output, 52 | Err(err) => format!("{:?}", err).red().to_string(), 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/commands/remove.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | 3 | use crate::{ 4 | config::{color::ColorScheme, ExternalCommands, SurfParsing}, 5 | database::{Database, SqliteAsyncHandle}, 6 | highlight::MarkdownStatic, 7 | note::{Note, PreviewType}, 8 | print::format_two_tokens, 9 | skim::open::Iteration, 10 | }; 11 | use colored::Colorize; 12 | 13 | use inquire::Confirm; 14 | 15 | pub(crate) async fn exec( 16 | db: SqliteAsyncHandle, 17 | external_commands: ExternalCommands, 18 | surf_parsing: SurfParsing, 19 | md_static: MarkdownStatic, 20 | color_scheme: ColorScheme, 21 | ) -> Result { 22 | let list = db.lock().await.list(md_static, color_scheme).await?; 23 | 24 | let straight = true; 25 | let multi = false; 26 | let nested_threshold = 1; 27 | let note = Iteration::new( 28 | "remove".to_string(), 29 | list, 30 | db.clone(), 31 | multi, 32 | PreviewType::Details, 33 | external_commands.clone(), 34 | surf_parsing, 35 | md_static, 36 | color_scheme, 37 | straight, 38 | nested_threshold, 39 | ) 40 | .run() 41 | .await?; 42 | remove(db, note, false).await?; 43 | 44 | Ok("success".truecolor(0, 255, 255).to_string()) 45 | } 46 | 47 | pub(crate) async fn remove( 48 | db: SqliteAsyncHandle, 49 | note: Note, 50 | confirm: bool, 51 | ) -> Result { 52 | if confirm { 53 | let ans = Confirm::new(&format!("sure you want to delete `{}`", note.name())).prompt(); 54 | match ans { 55 | Ok(true) => {} 56 | Ok(false) => return Ok(false), 57 | Err(err) => { 58 | return Err(err)?; 59 | } 60 | } 61 | } 62 | db.lock().await.remove_note(¬e).await?; 63 | 64 | if let Some(file_path) = note.file_path() { 65 | fs::remove_file(file_path)?; 66 | } 67 | eprintln!( 68 | "{}", 69 | format_two_tokens( 70 | "removed ", 71 | &format!("{}, {:?}", note.name(), note.file_path()) 72 | ) 73 | ); 74 | Ok(true) 75 | } 76 | -------------------------------------------------------------------------------- /src/highlight.rs: -------------------------------------------------------------------------------- 1 | use syntect::easy::HighlightLines; 2 | use syntect::highlighting::{Style, Theme, ThemeSet}; 3 | use syntect::parsing::{SyntaxReference, SyntaxSet}; 4 | use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings}; 5 | 6 | pub(super) fn highlight_code_block( 7 | input: &str, 8 | syntax_desc: &str, 9 | md_static: MarkdownStatic, 10 | ) -> String { 11 | let (syntax_set, _, theme) = md_static; 12 | let syntax = syntax_set.find_syntax_by_token(syntax_desc); 13 | if let Some(syntax) = syntax { 14 | let mut result = String::new(); 15 | 16 | let mut h = HighlightLines::new(syntax, theme); 17 | for line in LinesWithEndings::from(input) { 18 | // LinesWithEndings enables use of newlines mode 19 | let ranges: Vec<(Style, &str)> = h.highlight_line(line, syntax_set).unwrap(); 20 | let escaped = as_24_bit_terminal_escaped(&ranges[..], true); 21 | result.push_str(&escaped); 22 | } 23 | result 24 | } else { 25 | input.to_string() 26 | } 27 | } 28 | 29 | pub type MarkdownStatic = (&'static SyntaxSet, &'static SyntaxReference, &'static Theme); 30 | 31 | pub(super) fn static_markdown_syntax(loaded_theme: Option<&'static Theme>) -> MarkdownStatic { 32 | let syntax_set = Box::new(SyntaxSet::load_defaults_newlines()); 33 | let static_synt_set: &'static mut SyntaxSet = Box::leak(syntax_set); 34 | let syntax_md: &'static SyntaxReference = 35 | static_synt_set.find_syntax_by_token("markdown").unwrap(); 36 | 37 | let theme_set_default = Box::new(ThemeSet::load_defaults()); 38 | let them_set_static: &'static mut ThemeSet = Box::leak(theme_set_default); 39 | 40 | let theme: &'static Theme = if let Some(loaded) = loaded_theme { 41 | loaded 42 | } else { 43 | &them_set_static.themes["base16-eighties.dark"] 44 | }; 45 | (static_synt_set, syntax_md, theme) 46 | } 47 | 48 | pub fn highlight(input: &str, h: &mut HighlightLines, md_static: MarkdownStatic) -> String { 49 | let mut result = String::new(); 50 | for line in LinesWithEndings::from(input) { 51 | // LinesWithEndings enables use of newlines mode 52 | let ranges: Vec<(Style, &str)> = h.highlight_line(line, md_static.0).unwrap(); 53 | let escaped = as_24_bit_terminal_escaped(&ranges[..], true); 54 | result.push_str(&escaped); 55 | } 56 | result 57 | } 58 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # configuration file for git-cliff 2 | # see https://github.com/orhun/git-cliff#configuration-file 3 | 4 | [changelog] 5 | # changelog header 6 | header = """ 7 | # Changelog\n 8 | All notable changes to this project will be documented in this file.\n 9 | """ 10 | # template for the changelog body 11 | # https://tera.netlify.app/docs/#introduction 12 | body = """ 13 | {% if version %}\ 14 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 15 | {% else %}\ 16 | ## [unreleased] 17 | {% endif %}\ 18 | {% for group, commits in commits | group_by(attribute="group") %} 19 | ### {{ group | upper_first }} 20 | {% for commit in commits %} 21 | - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ 22 | {% endfor %} 23 | {% endfor %}\n 24 | """ 25 | # remove the leading and trailing whitespace from the template 26 | trim = true 27 | # changelog footer 28 | footer = """ 29 | 30 | """ 31 | 32 | [git] 33 | # parse the commits based on https://www.conventionalcommits.org 34 | conventional_commits = true 35 | # filter out the commits that are not conventional 36 | filter_unconventional = true 37 | # process each line of a commit as an individual commit 38 | split_commits = false 39 | # regex for preprocessing the commit messages 40 | commit_preprocessors = [ 41 | # { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/orhun/git-cliff/issues/${2}))"}, # replace issue numbers 42 | ] 43 | # regex for parsing and grouping commits 44 | commit_parsers = [ 45 | { message = "^feat", group = "Features"}, 46 | { message = "^fix", group = "Bug Fixes"}, 47 | { message = "^doc", group = "Documentation"}, 48 | { message = "^perf", group = "Performance"}, 49 | { message = "^refactor", group = "Refactor"}, 50 | { message = "^style", group = "Styling"}, 51 | { message = "^test", group = "Testing"}, 52 | { message = "^chore\\(release\\): prepare for", skip = true}, 53 | { message = "^chore", group = "Miscellaneous Tasks"}, 54 | { body = ".*security", group = "Security"}, 55 | ] 56 | # protect breaking changes from being skipped due to matching a skipping commit_parser 57 | protect_breaking_commits = false 58 | # filter out the commits that are not matched by commit parsers 59 | filter_commits = false 60 | # glob pattern for matching git tags 61 | tag_pattern = "v[0-9]*" 62 | # regex for skipping tags 63 | skip_tags = "v0.1.0-beta.1" 64 | # regex for ignoring tags 65 | ignore_tags = "" 66 | # sort the tags topologically 67 | topo_order = false 68 | # sort the commits inside sections by oldest/newest order 69 | sort_commits = "oldest" 70 | # limit the number of commits included in the changelog. 71 | # limit_commits = 42 72 | -------------------------------------------------------------------------------- /src/config/external_commands/cmd_template.rs: -------------------------------------------------------------------------------- 1 | use kdl::KdlNode; 2 | 3 | use crate::config::KdlNodeErrorType; 4 | 5 | #[derive(Debug, Clone)] 6 | pub struct CmdTemplate { 7 | pub command: String, 8 | pub args: Vec, 9 | } 10 | impl TryFrom<&KdlNode> for CmdTemplate { 11 | type Error = miette::Report; 12 | 13 | fn try_from(value: &KdlNode) -> Result { 14 | let entries = value.entries(); 15 | let first = entries.first(); 16 | let Some(first) = first else { 17 | return Err(KdlNodeErrorType { 18 | err_span: *value.span(), 19 | description: "node expected to have at least 1 argument".to_string(), 20 | }) 21 | .map_err(Into::::into)?; 22 | }; 23 | 24 | let command = first 25 | .value() 26 | .as_string() 27 | .ok_or(KdlNodeErrorType { 28 | err_span: *first.span(), 29 | description: "all of arguments' values are expected to be of string type" 30 | .to_string(), 31 | }) 32 | .map_err(Into::::into)? 33 | .to_string(); 34 | 35 | let args = &entries[1..]; 36 | let args: Result, Self::Error> = args 37 | .iter() 38 | .map(|arg| { 39 | Ok(arg 40 | .value() 41 | .as_string() 42 | .ok_or(KdlNodeErrorType { 43 | err_span: *arg.span(), 44 | description: "all of arguments' values are expected to be of string type" 45 | .to_string(), 46 | }) 47 | .map_err(Into::::into)? 48 | .to_string()) 49 | }) 50 | .collect(); 51 | 52 | Ok(Self { 53 | command, 54 | args: args?, 55 | }) 56 | } 57 | } 58 | 59 | impl CmdTemplate { 60 | pub fn replace_matching_element(&mut self, placeholder: &str, value: &str) { 61 | if let Some(index) = self.args.iter().position(|x| x == placeholder) { 62 | self.args[index] = value.to_string(); 63 | } 64 | } 65 | 66 | pub fn replace_in_matching_element(&mut self, placeholder: &str, value: &str) { 67 | let args = self.args.drain(..).collect::>(); 68 | self.args = args 69 | .into_iter() 70 | .map(|element| { 71 | if element.contains(placeholder) { 72 | let new = element.replace(placeholder, value); 73 | return new; 74 | } 75 | element 76 | }) 77 | .collect::>(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/note/skim_item/preview/structure.rs: -------------------------------------------------------------------------------- 1 | use colored::Colorize; 2 | 3 | use crate::{ 4 | config::color::ColorScheme, database::SqliteAsyncHandle, highlight::MarkdownStatic, note::Note, 5 | }; 6 | 7 | use std::collections::HashSet; 8 | 9 | impl Note { 10 | pub async fn link_structure( 11 | &self, 12 | db: &SqliteAsyncHandle, 13 | md_static: MarkdownStatic, 14 | color_scheme: ColorScheme, 15 | straight: bool, 16 | nested_threshold: usize, 17 | ) -> String { 18 | let rs = self.resources().unwrap(); 19 | 20 | if straight { 21 | let result = self 22 | .construct_link_term_tree( 23 | 0, 24 | nested_threshold, 25 | HashSet::new(), 26 | rs.external_commands.clone(), 27 | rs.surf_parsing.clone(), 28 | db.clone(), 29 | md_static, 30 | color_scheme, 31 | ) 32 | .await; 33 | match result { 34 | Ok((tree, _)) => format!("{}", tree), 35 | Err(err) => format!("db err {:?}", err).truecolor(255, 0, 0).to_string(), 36 | } 37 | } else { 38 | let result = self 39 | .construct_link_term_tree_up( 40 | 0, 41 | nested_threshold, 42 | HashSet::new(), 43 | rs.external_commands.clone(), 44 | rs.surf_parsing.clone(), 45 | db.clone(), 46 | md_static, 47 | color_scheme, 48 | ) 49 | .await; 50 | match result { 51 | Ok((tree, _)) => format!("{}", tree), 52 | Err(err) => format!("db err {:?}", err).truecolor(255, 0, 0).to_string(), 53 | } 54 | } 55 | } 56 | 57 | pub async fn task_structure( 58 | &self, 59 | db: &SqliteAsyncHandle, 60 | md_static: MarkdownStatic, 61 | color_scheme: ColorScheme, 62 | straight: bool, 63 | nested_threshold: usize, 64 | ) -> String { 65 | let rs = self.resources().unwrap(); 66 | let result = self 67 | .construct_task_item_term_tree( 68 | 0, 69 | nested_threshold, 70 | HashSet::new(), 71 | rs.surf_parsing.clone(), 72 | db.clone(), 73 | md_static, 74 | color_scheme, 75 | straight, 76 | ) 77 | .await; 78 | 79 | match result { 80 | Ok((tree, _)) => format!("{}", tree), 81 | Err(err) => format!("db err {:?}", err).truecolor(255, 0, 0).to_string(), 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/note/skim_item.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use skim::{AnsiString, DisplayContext, ItemPreview, PreviewContext, SkimItem}; 4 | 5 | use crate::{config::color::ColorScheme, database::SqliteAsyncHandle, highlight::MarkdownStatic}; 6 | 7 | use super::PreviewType; 8 | 9 | mod preview; 10 | 11 | impl super::Note { 12 | async fn compute_preview( 13 | &self, 14 | db: &SqliteAsyncHandle, 15 | md_static: MarkdownStatic, 16 | color_scheme: ColorScheme, 17 | straight: bool, 18 | nested_threshold: usize, 19 | ) -> Option { 20 | match self.resources() { 21 | Some(resources) => { 22 | let result = match resources.preview_type { 23 | PreviewType::Details => { 24 | self.details(db, md_static, color_scheme, straight).await 25 | } 26 | PreviewType::LinkStructure => { 27 | self.link_structure(db, md_static, color_scheme, straight, nested_threshold) 28 | .await 29 | } 30 | PreviewType::TaskStructure => { 31 | self.task_structure(db, md_static, color_scheme, straight, nested_threshold) 32 | .await 33 | } 34 | }; 35 | Some(result) 36 | } 37 | None => None, 38 | } 39 | } 40 | pub async fn prepare_preview( 41 | &mut self, 42 | db: &SqliteAsyncHandle, 43 | md_static: MarkdownStatic, 44 | color_scheme: ColorScheme, 45 | straight: bool, 46 | nested_threshold: usize, 47 | ) { 48 | let result = self 49 | .compute_preview(db, md_static, color_scheme, straight, nested_threshold) 50 | .await; 51 | if let Some(resources) = self.resources_mut() { 52 | resources.preview_result = result; 53 | } 54 | } 55 | } 56 | 57 | impl SkimItem for super::Note { 58 | fn text(&self) -> Cow { 59 | Cow::Owned(self.name()) 60 | } 61 | fn display<'a>(&'a self, _context: DisplayContext<'a>) -> AnsiString<'a> { 62 | let input = format!("{}", self); 63 | let ansistring = AnsiString::parse(&input); 64 | ansistring 65 | } 66 | 67 | fn preview(&self, _context: PreviewContext) -> ItemPreview { 68 | match self.resources() { 69 | Some(resources) => { 70 | if let Some(ref result) = resources.preview_result { 71 | ItemPreview::AnsiText(result.clone()) 72 | } else { 73 | ItemPreview::Text("".to_string()) 74 | } 75 | } 76 | None => ItemPreview::Text("".to_string()), 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/commands/unlink.rs: -------------------------------------------------------------------------------- 1 | use colored::Colorize; 2 | 3 | use crate::{ 4 | config::{color::ColorScheme, ExternalCommands, SurfParsing}, 5 | database::{Database, SqliteAsyncHandle}, 6 | highlight::MarkdownStatic, 7 | note::{Note, PreviewType}, 8 | print::format_two_tokens, 9 | skim::open::Iteration, 10 | }; 11 | 12 | pub(crate) async fn exec( 13 | db: SqliteAsyncHandle, 14 | external_commands: ExternalCommands, 15 | surf_parsing: SurfParsing, 16 | 17 | md_static: MarkdownStatic, 18 | color_scheme: ColorScheme, 19 | ) -> Result { 20 | let list = db.lock().await.list(md_static, color_scheme).await?; 21 | let straight = true; 22 | 23 | let multi = false; 24 | let nested_threshold = 1; 25 | let from = Iteration::new( 26 | "unlink from".to_string(), 27 | list, 28 | db.clone(), 29 | multi, 30 | PreviewType::Details, 31 | external_commands.clone(), 32 | surf_parsing.clone(), 33 | md_static, 34 | color_scheme, 35 | straight, 36 | nested_threshold, 37 | ) 38 | .run() 39 | .await?; 40 | 41 | unlink( 42 | from, 43 | db, 44 | &external_commands, 45 | &surf_parsing, 46 | md_static, 47 | color_scheme, 48 | straight, 49 | ) 50 | .await?; 51 | 52 | Ok("success".truecolor(0, 255, 255).to_string()) 53 | } 54 | 55 | pub(crate) async fn unlink( 56 | from: Note, 57 | db: SqliteAsyncHandle, 58 | external_commands: &ExternalCommands, 59 | surf_parsing: &SurfParsing, 60 | md_static: MarkdownStatic, 61 | color_scheme: ColorScheme, 62 | straight: bool, 63 | ) -> Result<(), anyhow::Error> { 64 | let name: String = from.name().chars().take(40).collect(); 65 | 66 | let hint = format!("unlink from {}", name); 67 | let nested_threshold = 1; 68 | 69 | let forward_links = db 70 | .lock() 71 | .await 72 | .find_links_from(&from.name(), md_static, color_scheme, straight) 73 | .await?; 74 | let to = Iteration::new( 75 | hint, 76 | forward_links, 77 | db.clone(), 78 | false, 79 | PreviewType::Details, 80 | external_commands.clone(), 81 | surf_parsing.clone(), 82 | md_static, 83 | color_scheme, 84 | straight, 85 | nested_threshold, 86 | ) 87 | .run() 88 | .await?; 89 | 90 | db.lock() 91 | .await 92 | .remove_link(&from.name(), &to.name(), straight) 93 | .await?; 94 | eprintln!( 95 | "{}", 96 | format_two_tokens( 97 | "unlinked: ", 98 | &format!("\"{}\" -> \"{}\"", from.name(), to.name()) 99 | ) 100 | ); 101 | Ok(()) 102 | } 103 | -------------------------------------------------------------------------------- /src/commands/link.rs: -------------------------------------------------------------------------------- 1 | use colored::Colorize; 2 | 3 | use crate::{ 4 | config::{color::ColorScheme, ExternalCommands, SurfParsing}, 5 | database::{Database, SqliteAsyncHandle}, 6 | highlight::MarkdownStatic, 7 | note::{Note, PreviewType}, 8 | print::format_two_tokens, 9 | skim::open::Iteration, 10 | }; 11 | 12 | pub(crate) async fn exec( 13 | db: SqliteAsyncHandle, 14 | external_commands: ExternalCommands, 15 | surf_parsing: SurfParsing, 16 | md_static: MarkdownStatic, 17 | color_scheme: ColorScheme, 18 | ) -> Result { 19 | let list = db.lock().await.list(md_static, color_scheme).await?; 20 | let straight = true; 21 | 22 | let multi = false; 23 | let nested_threshold = 1; 24 | 25 | let from = Iteration::new( 26 | "link from".to_string(), 27 | list.clone(), 28 | db.clone(), 29 | multi, 30 | PreviewType::Details, 31 | external_commands.clone(), 32 | surf_parsing.clone(), 33 | md_static, 34 | color_scheme, 35 | straight, 36 | nested_threshold, 37 | ) 38 | .run() 39 | .await?; 40 | 41 | link( 42 | from, 43 | db, 44 | &external_commands, 45 | &surf_parsing, 46 | md_static, 47 | color_scheme, 48 | straight, 49 | nested_threshold, 50 | ) 51 | .await?; 52 | 53 | Ok("success".truecolor(0, 255, 255).to_string()) 54 | } 55 | 56 | #[allow(clippy::too_many_arguments)] 57 | pub(crate) async fn link( 58 | from: Note, 59 | db: SqliteAsyncHandle, 60 | external_commands: &ExternalCommands, 61 | surf_parsing: &SurfParsing, 62 | md_static: MarkdownStatic, 63 | color_scheme: ColorScheme, 64 | straight: bool, 65 | nested_threshold: usize, 66 | ) -> Result<(), anyhow::Error> { 67 | let name: String = from.name().chars().take(40).collect(); 68 | 69 | let hint = format!("link from {}", name); 70 | let list = db.lock().await.list(md_static, color_scheme).await?; 71 | let to = Iteration::new( 72 | hint, 73 | list, 74 | db.clone(), 75 | false, 76 | PreviewType::Details, 77 | external_commands.clone(), 78 | surf_parsing.clone(), 79 | md_static, 80 | color_scheme, 81 | straight, 82 | nested_threshold, 83 | ) 84 | .run() 85 | .await?; 86 | 87 | link_noninteractive(from, to, db, straight).await?; 88 | Ok(()) 89 | } 90 | 91 | pub(crate) async fn link_noninteractive( 92 | from: Note, 93 | to: Note, 94 | db: SqliteAsyncHandle, 95 | straight: bool, 96 | ) -> Result<(), anyhow::Error> { 97 | db.lock() 98 | .await 99 | .insert_link(&from.name(), &to.name(), straight) 100 | .await?; 101 | eprintln!( 102 | "{}", 103 | format_two_tokens( 104 | "linked: ", 105 | &format!("\"{}\" -> \"{}\"", from.name(), to.name()) 106 | ) 107 | ); 108 | Ok(()) 109 | } 110 | -------------------------------------------------------------------------------- /src/link/skim_item.rs: -------------------------------------------------------------------------------- 1 | use colored::Colorize; 2 | use std::borrow::Cow; 3 | 4 | use skim::{AnsiString, DisplayContext, ItemPreview, PreviewContext, SkimItem}; 5 | 6 | use crate::{ 7 | config::{color::ColorScheme, Preview}, 8 | external_commands::{fetch_content, fetch_content_range, list_dir}, 9 | highlight::{highlight_code_block, MarkdownStatic}, 10 | }; 11 | 12 | impl super::Link { 13 | pub fn prepare_display(&mut self) { 14 | let input = self.skim_display(); 15 | self.display_item = Some(AnsiString::parse(&input)); 16 | } 17 | 18 | pub fn compute_preview( 19 | &self, 20 | preview_cmds: &Preview, 21 | md_static: MarkdownStatic, 22 | color_scheme: ColorScheme, 23 | ) -> String { 24 | match &self.link { 25 | super::Destination::Url(url) => { 26 | let c = color_scheme.links.url; 27 | url.truecolor(c.0.r, c.0.g, c.0.b).to_string() 28 | } 29 | super::Destination::File { file } => { 30 | fetch_content(preview_cmds.file_cmd.clone(), Some(file)).unwrap() 31 | } 32 | super::Destination::FileLine { file, line_number } => { 33 | fetch_content_range(preview_cmds.file_line_cmd.clone(), Some(file), *line_number) 34 | .unwrap() 35 | } 36 | super::Destination::Dir { dir } => list_dir(preview_cmds.dir_cmd.clone(), dir), 37 | super::Destination::Broken(broken, line) => { 38 | let line = if let Some(line) = line { 39 | format!("", line) 40 | } else { 41 | String::new() 42 | }; 43 | let c = color_scheme.links.broken; 44 | format!( 45 | "{}: {} {}", 46 | "Broken path", 47 | broken 48 | .to_str() 49 | .unwrap_or("not valid unicode") 50 | .truecolor(c.0.r, c.0.g, c.0.b), 51 | line, 52 | ) 53 | } 54 | 55 | super::Destination::CodeBlock { 56 | code_block, 57 | syntax_label, 58 | .. 59 | } => highlight_code_block(code_block, syntax_label, md_static), 60 | } 61 | } 62 | 63 | pub fn prepare_preview( 64 | &mut self, 65 | preview_cmds: &Preview, 66 | md_static: MarkdownStatic, 67 | color_scheme: ColorScheme, 68 | ) { 69 | let result = self.compute_preview(preview_cmds, md_static, color_scheme); 70 | self.preview_item = Some(result); 71 | } 72 | } 73 | 74 | impl SkimItem for super::Link { 75 | fn text(&self) -> Cow { 76 | Cow::Owned(format!("{}", self)) 77 | } 78 | fn display<'a>(&'a self, _context: DisplayContext<'a>) -> AnsiString<'a> { 79 | if let Some(ref string) = self.display_item { 80 | string.clone() 81 | } else { 82 | AnsiString::parse("") 83 | } 84 | } 85 | 86 | fn preview(&self, _context: PreviewContext) -> ItemPreview { 87 | if let Some(ref string) = self.preview_item { 88 | ItemPreview::AnsiText(string.clone()) 89 | } else { 90 | ItemPreview::AnsiText("".to_string()) 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/config/keymap/single_key.rs: -------------------------------------------------------------------------------- 1 | use kdl::KdlNode; 2 | use lazy_static::lazy_static; 3 | use regex::Regex; 4 | 5 | use crate::config::KdlNodeErrorType; 6 | 7 | lazy_static! { 8 | static ref KEY_COMBO_REGEX: Regex = Regex::new("^(ctrl-.)$|^(alt-.)$").expect("wrong regex"); 9 | } 10 | 11 | #[derive(Debug, Clone, Eq, PartialEq, Hash)] 12 | pub struct SingleKey { 13 | pub combo: String, 14 | pub tui_combo: tuikit::key::Key, 15 | } 16 | 17 | impl SingleKey { 18 | fn from_string_repr(combo: &str) -> tuikit::key::Key { 19 | if combo.starts_with("ctrl") { 20 | let char = combo.chars().take(6).last().expect("doesnt match regex"); 21 | tuikit::key::Key::Ctrl(char) 22 | } else if combo.starts_with("alt") { 23 | let char = combo.chars().take(5).last().expect("doesnt match regex"); 24 | tuikit::key::Key::Alt(char) 25 | } else { 26 | unreachable!("should be unreachable due to `combo` matching KEY_COMBO_REGEX") 27 | } 28 | } 29 | } 30 | 31 | impl TryFrom for SingleKey { 32 | type Error = String; 33 | fn try_from(combo: String) -> Result { 34 | if !KEY_COMBO_REGEX.is_match(&combo) { 35 | return Err(format!( 36 | "`{}` doesn't match regex {}", 37 | combo, 38 | KEY_COMBO_REGEX.as_str() 39 | )); 40 | } 41 | 42 | let tui_combo = Self::from_string_repr(&combo); 43 | if tui_combo == tuikit::key::Key::Ctrl('c') { 44 | return Err("`ctrl-c` binding is forbidden".to_string()); 45 | } 46 | Ok(Self { combo, tui_combo }) 47 | } 48 | } 49 | impl TryFrom<&KdlNode> for SingleKey { 50 | type Error = miette::Report; 51 | 52 | fn try_from(value: &KdlNode) -> Result { 53 | let combo = value 54 | .get(0) 55 | .ok_or(KdlNodeErrorType { 56 | err_span: *value.span(), 57 | description: "node's first argument not found".to_string(), 58 | }) 59 | .map_err(Into::::into)? 60 | .value() 61 | .as_string() 62 | .ok_or(KdlNodeErrorType { 63 | err_span: *value.span(), 64 | description: "argument's value is expected to be of string type".to_string(), 65 | }) 66 | .map_err(Into::::into)? 67 | .to_string(); 68 | 69 | combo.try_into().map_err(|err| { 70 | let err = KdlNodeErrorType { 71 | err_span: *value.span(), 72 | description: err, 73 | }; 74 | 75 | Into::::into(err) 76 | }) 77 | } 78 | } 79 | 80 | #[cfg(test)] 81 | mod tests { 82 | use super::SingleKey; 83 | 84 | #[test] 85 | fn test_correct_parsing() { 86 | let ctrl_combo = "ctrl-t".to_string(); 87 | let alt_combo = "alt-t".to_string(); 88 | 89 | let key_first: SingleKey = ctrl_combo.try_into().expect("no err"); 90 | let key_second: SingleKey = alt_combo.try_into().expect("no err"); 91 | 92 | let f_expected = SingleKey { 93 | combo: "ctrl-t".to_string(), 94 | tui_combo: tuikit::key::Key::Ctrl('t'), 95 | }; 96 | let f_expected2 = SingleKey { 97 | combo: "alt-t".to_string(), 98 | tui_combo: tuikit::key::Key::Alt('t'), 99 | }; 100 | assert_eq!(key_first, f_expected); 101 | assert_eq!(key_second, f_expected2); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/note/skim_item/preview/details.rs: -------------------------------------------------------------------------------- 1 | use colored::Colorize; 2 | 3 | use crate::config::color::ColorScheme; 4 | use crate::database::SqliteAsyncHandle; 5 | use crate::highlight::MarkdownStatic; 6 | use crate::note::Note; 7 | use comfy_table::presets::UTF8_FULL; 8 | use comfy_table::{Cell, Color, ContentArrangement, Table}; 9 | 10 | use sqlx::Error; 11 | 12 | use crate::external_commands::fetch_content; 13 | use crate::print::format_two_tokens; 14 | 15 | fn map_db_result(received: Result, Error>) -> String { 16 | match received { 17 | Ok(list) => { 18 | if !list.is_empty() { 19 | let mut table = Table::new(); 20 | table 21 | .load_preset(UTF8_FULL) 22 | .set_content_arrangement(ContentArrangement::Dynamic) 23 | .set_width(80) 24 | .set_header(vec![ 25 | Cell::new("Name").fg(Color::Blue), 26 | Cell::new("Type").fg(Color::Blue), 27 | ]); 28 | list.into_iter().for_each(|note| { 29 | let is_tag = note.file_path().is_none(); 30 | let color = if is_tag { 31 | Color::Cyan 32 | } else { 33 | Color::DarkYellow 34 | }; 35 | let type_column = if is_tag { "tag" } else { "note" }; 36 | table.add_row(vec![ 37 | Cell::new(note.name()), 38 | Cell::new(type_column).fg(color), 39 | ]); 40 | }); 41 | format!("{}\n", table) 42 | } else { 43 | String::new() 44 | } 45 | } 46 | Err(err) => format!("db err {:?}", err).truecolor(255, 0, 0).to_string(), 47 | } 48 | } 49 | 50 | fn map_result(query_result: Result, Error>, tag: String) -> String { 51 | let links_to = map_db_result(query_result); 52 | 53 | let mut string = String::new(); 54 | if !links_to.is_empty() { 55 | string.push_str(&tag); 56 | string.push('\n'); 57 | string.push_str(&links_to); 58 | string.push('\n'); 59 | } 60 | string 61 | } 62 | 63 | impl Note { 64 | pub async fn details( 65 | &self, 66 | db: &SqliteAsyncHandle, 67 | md_static: MarkdownStatic, 68 | color_scheme: ColorScheme, 69 | straight: bool, 70 | ) -> String { 71 | let result_from = self 72 | .fetch_forward_links(db, md_static, color_scheme, straight) 73 | .await; 74 | let result_to = self 75 | .fetch_backlinks(db, md_static, color_scheme, straight) 76 | .await; 77 | let links_to = map_result(result_from, "Links to:".to_string()); 78 | let linked_by = map_result(result_to, "Linked by:".to_string()); 79 | let mut string = String::new(); 80 | let title = if self.file_path().is_some() { 81 | format_two_tokens("it's a note:", &self.name()) 82 | } else { 83 | format_two_tokens("it's a tag:", &self.name()) 84 | }; 85 | string.push_str(&title); 86 | string.push_str("\n\n"); 87 | string.push_str(&linked_by); 88 | string.push_str(&links_to); 89 | if let Some(resources) = self.resources() { 90 | let body = fetch_content( 91 | resources.external_commands.preview.file_cmd.clone(), 92 | self.file_path(), 93 | ); 94 | if body.is_some() { 95 | string.push_str(&body.unwrap()); 96 | } 97 | } 98 | string 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /config.kdl: -------------------------------------------------------------------------------- 1 | world { 2 | external-commands { 3 | preview { 4 | dir "exa" "-l" "--all" "--color=always" "--group-directories-first" "--git" "$DIR" 5 | file "bat" "--color=always" "$FILE" 6 | file-line "bat" "--color=always" "--line-range" "$FIRST:$LAST" "-H" "$LINE" "$FILE" 7 | } 8 | open { 9 | file "$EDITOR" "$FILE" 10 | file-jump "$EDITOR" "$FILE:$LINE:$COLUMN" 11 | // file-jump "nvim" "$FILE" "+call cursor($LINE, $COLUMN)" 12 | dir "zellij" "action" "new-pane" "--cwd" "$DIR" "--" "broot" 13 | url "firefox" "$URL" 14 | pipe-$SNIPPET_TEXT-into "wl-copy" 15 | } 16 | 17 | } 18 | surf-parsing { 19 | // regex can be arbitrary, but the named groups `description`, `url` 20 | // must exist, otherwise panics will entail 21 | markdown-reference-link-regex r#"\[(?P[^\]]+)\]\((?P[^\)]+)\)"# 22 | url-regex r#"^https?://\S+"# 23 | 24 | file-dest-has-line-regex r#".*:[0-9]+$"# 25 | // regex can be arbitrary, but the named groups `whitespace`, `checkmark`, `task_text` 26 | // must exist, otherwise panics will entail. 27 | // checkmark must be one char, either ' ' or 'x', otherwise the behaviour is hard 28 | // to predict. 29 | // 30 | // the nesting will be computed as the the length of `whitespace` group in bytes 31 | // divided by 2 32 | task-item-regex r#"(?P( )*)- \[(?P[x ])\]\s+(?P.+)"# 33 | } 34 | notes-work-dir "/home/user/notes" 35 | keymap { 36 | explore { 37 | open_xdg "ctrl-o" 38 | populate_search_with_backlinks "ctrl-h" 39 | populate_search_with_forwardlinks "ctrl-l" 40 | toggle_preview_type "ctrl-t" 41 | widen_to_all_notes "ctrl-w" 42 | surf_note_subtree "ctrl-s" 43 | checkmark_note "ctrl-k" 44 | rename_note "alt-r" 45 | link_from_selected_note "alt-l" 46 | unlink_from_selected_note "alt-u" 47 | remove_note "alt-d" 48 | create_autolinked_note "alt-c" 49 | toggle_links_direction "alt-f" 50 | splice_reachable_children_of_note "alt-s" 51 | narrow_selection "alt-n" 52 | decrease_unlisted_threshold "alt-o" 53 | increase_unlisted_threshold "alt-p" 54 | push_note_to_stack "alt-a" 55 | switch_mode_to_stack "ctrl-a" 56 | } 57 | surf { 58 | open_xdg "ctrl-o" 59 | jump_to_link_or_snippet "ctrl-j" 60 | return_to_explore "ctrl-e" 61 | } 62 | checkmark { 63 | jump_to_task "ctrl-j" 64 | copy_task_subtree_into_clipboard "ctrl-y" 65 | widen_context_to_all_tasks "ctrl-w" 66 | narrow_context_to_selected_task_subtree "ctrl-l" 67 | return_to_explore "ctrl-e" 68 | } 69 | stack { 70 | toggle_preview_type "ctrl-t" 71 | pop_note_from_stack "alt-p" 72 | move_note_to_top_of_stack "alt-t" 73 | return_to_explore "ctrl-e" 74 | swap_with_above "alt-u" 75 | swap_with_below "alt-d" 76 | deselect_all "ctrl-q" 77 | 78 | } 79 | } 80 | 81 | color { 82 | // used for markdown of notes' names, task items' titles 83 | // 84 | // used for code snippets in various syntaxes (syntax is hinted by ```tag) 85 | theme "/home/user/Downloads/file.tmTheme" 86 | elements { 87 | links { 88 | parent_name r#" {"r":242,"g":242,"b":223} "# 89 | url r#" {"r":144,"g":238,"b":144} "# 90 | file r#" {"r":216,"g":191,"b":216} "# 91 | dir r#" {"r":147,"g":112,"b":219} "# 92 | broken r#" {"r":255,"g":0,"b":0} "# 93 | code_block r#" {"r":135,"g":206,"b":250} "# 94 | unlisted r#" {"r":180,"g":180,"b":180} "# 95 | cycle r#" {"r":210,"g":180,"b":140} "# 96 | } 97 | notes { 98 | tag r#" {"r":0,"g":255,"b":255} "# 99 | // `root` and 1 more special tag 100 | special_tag r#" {"r":255,"g":0,"b":0} "# 101 | } 102 | } 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::{fs, path::PathBuf}; 3 | 4 | use kdl::{KdlDocument, KdlNode}; 5 | use miette::{Diagnostic, IntoDiagnostic, Report, SourceSpan}; 6 | use thiserror::Error; 7 | 8 | use crate::impl_try_from_kdl_node_tagged; 9 | use crate::print::format_two_tokens; 10 | 11 | use self::color::Color; 12 | pub use self::external_commands::cmd_template::CmdTemplate; 13 | pub use self::external_commands::{ExternalCommands, Open, Preview}; 14 | use self::keymap::Keymap; 15 | pub use self::surf_parsing::SurfParsing; 16 | 17 | pub mod macros; 18 | 19 | pub mod color; 20 | pub mod external_commands; 21 | pub mod keymap; 22 | pub mod surf_parsing; 23 | 24 | static PROGRAM_NAME: &str = "mds"; 25 | #[derive(Debug)] 26 | pub struct Config { 27 | pub work_dir: ConfigPath, 28 | pub surf_parsing: SurfParsing, 29 | pub external_commands: ExternalCommands, 30 | pub color: Color, 31 | pub keymap: Keymap, 32 | } 33 | 34 | #[derive(Debug, Clone)] 35 | pub struct ConfigPath(pub PathBuf); 36 | 37 | #[derive(Diagnostic, Debug, Error)] 38 | #[error("error associated with a kdl doc/node")] 39 | #[diagnostic()] 40 | pub struct KdlNodeErrorType { 41 | // Note: label but no source code 42 | #[label] 43 | err_span: SourceSpan, 44 | 45 | #[help] 46 | description: String, 47 | } 48 | 49 | impl TryFrom<&KdlNode> for ConfigPath { 50 | type Error = miette::Report; 51 | 52 | fn try_from(value: &KdlNode) -> Result { 53 | let string = value 54 | .get(0) 55 | .ok_or(KdlNodeErrorType { 56 | err_span: *value.span(), 57 | description: "node's first argument not found".to_string(), 58 | }) 59 | .map_err(Into::::into)? 60 | .value() 61 | .as_string() 62 | .ok_or(KdlNodeErrorType { 63 | err_span: *value.span(), 64 | description: "argument's value is expected to be of string type".to_string(), 65 | }) 66 | .map_err(Into::::into)? 67 | .to_string(); 68 | 69 | Ok(Self(PathBuf::from(string))) 70 | } 71 | } 72 | 73 | impl Config { 74 | pub fn parse() -> miette::Result { 75 | let xdg_dirs = xdg::BaseDirectories::with_prefix(PROGRAM_NAME).into_diagnostic()?; 76 | 77 | let config_path = xdg_dirs.get_config_file("config.kdl"); 78 | eprintln!( 79 | "{} \n", 80 | format_two_tokens( 81 | "expected config path: ", 82 | config_path.to_str().unwrap_or("bad utf8") 83 | ) 84 | ); 85 | 86 | let config_file = fs::read_to_string(config_path).into_diagnostic()?; 87 | 88 | let doc: KdlDocument = config_file.parse().map_err(Report::new)?; 89 | let world_node: miette::Result<&KdlNode> = doc 90 | .get("world") 91 | .ok_or(KdlNodeErrorType { 92 | err_span: *doc.span(), 93 | description: "couldn't find top-level `world` node in kdl document".to_string(), 94 | }) 95 | .map_err(Into::::into); 96 | let world_node = world_node.map_err(|error| error.with_source_code(config_file.clone()))?; 97 | 98 | let result: miette::Result = world_node.try_into(); 99 | let result = result.map_err(|error| error.with_source_code(config_file))?; 100 | Ok(result) 101 | } 102 | } 103 | 104 | impl_try_from_kdl_node_tagged!(Config, "world", 105 | "surf-parsing" => surf_parsing, 106 | "notes-work-dir" => work_dir, 107 | "external-commands" => external_commands, 108 | "color" => color, 109 | "keymap" => keymap); 110 | -------------------------------------------------------------------------------- /src/commands/surf.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use tokio::time::sleep; 4 | 5 | use crate::{ 6 | config::{color::ColorScheme, keymap, ExternalCommands, SurfParsing}, 7 | database::{Database, SqliteAsyncHandle}, 8 | highlight::MarkdownStatic, 9 | link::Link, 10 | note::{Note, PreviewType}, 11 | print::format_two_tokens, 12 | skim::explore::Action, 13 | skim::surf::Action as SurfAction, 14 | skim::surf::Iteration as SurfIteration, 15 | Jump, Open, 16 | }; 17 | 18 | use super::explore::iteration; 19 | 20 | pub(crate) async fn exec( 21 | db: SqliteAsyncHandle, 22 | surf: SurfParsing, 23 | external_commands: ExternalCommands, 24 | md_static: MarkdownStatic, 25 | color_scheme: ColorScheme, 26 | bindings_map: keymap::surf::Bindings, 27 | explore_bindings_map: keymap::explore::Bindings, 28 | ) -> Result { 29 | let mut list = db.lock().await.list(md_static, color_scheme).await?; 30 | 31 | let mut preview_type = PreviewType::default(); 32 | let nested_threshold = 1; 33 | let straight = true; 34 | let note = loop { 35 | let (next_items, opened, preview_type_after) = iteration( 36 | db.clone(), 37 | list, 38 | &external_commands, 39 | &surf, 40 | preview_type, 41 | md_static, 42 | color_scheme, 43 | straight, 44 | nested_threshold, 45 | explore_bindings_map.clone(), 46 | ) 47 | .await?; 48 | preview_type = preview_type_after; 49 | list = next_items; 50 | 51 | if let Some(Action::Open(opened)) = opened { 52 | break opened; 53 | } 54 | }; 55 | 56 | let _exit_note = surf_note( 57 | note, 58 | db, 59 | &external_commands, 60 | &surf, 61 | md_static, 62 | color_scheme, 63 | straight, 64 | bindings_map, 65 | ) 66 | .await?; 67 | 68 | Ok("success".to_string()) 69 | } 70 | 71 | #[allow(clippy::too_many_arguments)] 72 | pub(crate) async fn surf_note( 73 | note: Note, 74 | db: SqliteAsyncHandle, 75 | external_commands: &ExternalCommands, 76 | surf: &SurfParsing, 77 | md_static: MarkdownStatic, 78 | color_scheme: ColorScheme, 79 | straight: bool, 80 | bindings_map: keymap::surf::Bindings, 81 | ) -> Result { 82 | loop { 83 | let all_vec = note 84 | .reachable_notes(db.clone(), md_static, color_scheme, straight, true) 85 | .await?; 86 | let links: std::io::Result> = all_vec 87 | .into_iter() 88 | .map(|v| Link::parse(&v, surf, color_scheme)) 89 | .collect(); 90 | let links: Vec<_> = links?.into_iter().flatten().collect(); 91 | let action = SurfIteration::new( 92 | links, 93 | false, 94 | external_commands.clone(), 95 | note.clone(), 96 | md_static, 97 | color_scheme, 98 | bindings_map.clone(), 99 | ) 100 | .run() 101 | .await?; 102 | eprintln!("{}", action); 103 | match action { 104 | SurfAction::Open(ref link) => { 105 | link.open(external_commands.clone().open)?; 106 | eprintln!("{}", link.preview_item.as_ref().unwrap()); 107 | } 108 | 109 | SurfAction::OpenXDG(ref link) => { 110 | link.open_xdg()?; 111 | eprintln!("{}", link.preview_item.as_ref().unwrap()); 112 | } 113 | SurfAction::Jump(ref link) => { 114 | link.jump(external_commands.clone().open)?; 115 | eprintln!("{}", link.preview_item.as_ref().unwrap()); 116 | } 117 | SurfAction::Return(note) => { 118 | return Ok(note); 119 | } 120 | } 121 | sleep(Duration::new(0, 500_000_000)).await; 122 | eprintln!("{}", format_two_tokens("surfed", ¬e.name())); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/config/keymap/explore.rs: -------------------------------------------------------------------------------- 1 | use crate::config::KdlNodeErrorType; 2 | use kdl::KdlNode; 3 | use std::collections::{HashMap, HashSet}; 4 | 5 | use crate::{impl_from_self_into_action_hashmap, impl_try_from_kdl_node_uniqueness_check}; 6 | 7 | use super::single_key::SingleKey; 8 | 9 | #[derive(Debug, Clone, Eq, PartialEq, Hash)] 10 | pub enum Action { 11 | OpenXDG, 12 | PopulateSearchWithBacklinks, 13 | PopulateSearchWithForwardlinks, 14 | TogglePreviewType, 15 | WidenToAllNotes, 16 | SurfNoteSubtree, 17 | CheckmarkNote, 18 | RenameNote, 19 | LinkFromSelectedNote, 20 | UnlinkFromSelectedNote, 21 | RemoveNote, 22 | CreateAutolinkedNote, 23 | ToggleLinksDirection, 24 | SpliceReachableChildrenOfNote, 25 | NarrowSelection, 26 | DecreaseUnlistedThreshold, 27 | IncreaseUnlistedThreshold, 28 | PushNoteToStack, 29 | SwitchModeToStack, 30 | } 31 | 32 | #[derive(Debug, Clone)] 33 | pub struct ExploreKeymap { 34 | pub open_xdg: SingleKey, 35 | pub populate_search_with_backlinks: SingleKey, 36 | pub populate_search_with_forwardlinks: SingleKey, 37 | pub toggle_preview_type: SingleKey, 38 | pub widen_to_all_notes: SingleKey, 39 | pub surf_note_subtree: SingleKey, 40 | pub checkmark_note: SingleKey, 41 | pub rename_note: SingleKey, 42 | pub link_from_selected_note: SingleKey, 43 | pub unlink_from_selected_note: SingleKey, 44 | pub remove_note: SingleKey, 45 | pub create_autolinked_note: SingleKey, 46 | pub toggle_links_direction: SingleKey, 47 | pub splice_reachable_children_of_note: SingleKey, 48 | pub narrow_selection: SingleKey, 49 | pub decrease_unlisted_threshold: SingleKey, 50 | pub increase_unlisted_threshold: SingleKey, 51 | pub push_note_to_stack: SingleKey, 52 | pub switch_mode_to_stack: SingleKey, 53 | } 54 | 55 | impl_try_from_kdl_node_uniqueness_check!( 56 | ExploreKeymap, 57 | "world.keymap.explore", 58 | open_xdg, 59 | populate_search_with_backlinks, 60 | populate_search_with_forwardlinks, 61 | toggle_preview_type, 62 | widen_to_all_notes, 63 | surf_note_subtree, 64 | checkmark_note, 65 | rename_note, 66 | link_from_selected_note, 67 | unlink_from_selected_note, 68 | remove_note, 69 | create_autolinked_note, 70 | toggle_links_direction, 71 | splice_reachable_children_of_note, 72 | narrow_selection, 73 | decrease_unlisted_threshold, 74 | increase_unlisted_threshold, 75 | push_note_to_stack, 76 | switch_mode_to_stack 77 | ); 78 | 79 | impl_from_self_into_action_hashmap!(ExploreKeymap, Action, 80 | Action::OpenXDG => open_xdg | "accept".to_string(), 81 | Action::PopulateSearchWithBacklinks => populate_search_with_backlinks | "accept".to_string(), 82 | Action::PopulateSearchWithForwardlinks => populate_search_with_forwardlinks | "accept".to_string(), 83 | Action::TogglePreviewType => toggle_preview_type | "accept".to_string(), 84 | Action::WidenToAllNotes => widen_to_all_notes | "accept".to_string(), 85 | Action::SurfNoteSubtree => surf_note_subtree | "accept".to_string(), 86 | Action::CheckmarkNote => checkmark_note | "accept".to_string(), 87 | Action::RenameNote => rename_note | "accept".to_string(), 88 | Action::LinkFromSelectedNote => link_from_selected_note | "accept".to_string(), 89 | Action::UnlinkFromSelectedNote => unlink_from_selected_note | "accept".to_string(), 90 | Action::RemoveNote => remove_note | "accept".to_string(), 91 | Action::CreateAutolinkedNote => create_autolinked_note | "accept".to_string(), 92 | Action::ToggleLinksDirection => toggle_links_direction | "accept".to_string(), 93 | Action::SpliceReachableChildrenOfNote => splice_reachable_children_of_note | "accept".to_string(), 94 | Action::NarrowSelection => narrow_selection | "accept".to_string(), 95 | Action::DecreaseUnlistedThreshold => decrease_unlisted_threshold | "accept".to_string(), 96 | Action::IncreaseUnlistedThreshold => increase_unlisted_threshold | "accept".to_string(), 97 | Action::PushNoteToStack => push_note_to_stack | "accept".to_string(), 98 | Action::SwitchModeToStack => switch_mode_to_stack | "accept".to_string() 99 | ); 100 | -------------------------------------------------------------------------------- /src/task_item/skim_item.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, fmt::Display}; 2 | 3 | use bidir_termtree::{Down, Tree}; 4 | use duct::cmd; 5 | use skim::{AnsiString, DisplayContext, ItemPreview, PreviewContext, SkimItem}; 6 | 7 | use crate::{note::NoteTaskItemTerm, Yank}; 8 | 9 | #[derive(Clone)] 10 | pub struct TaskTreeWrapper { 11 | pub data: (Tree, Tree), 12 | pub display_item: Option>, 13 | pub preview_item: Option, 14 | pub mono_preview_item: Option, 15 | } 16 | 17 | impl Yank for TaskTreeWrapper { 18 | fn yank(&self, cfg: crate::config::Open) -> std::io::Result> { 19 | println!("copy \n{}", self.data.0); 20 | let string = self.mono_preview_item.as_ref().cloned().unwrap(); 21 | 22 | Ok(Some( 23 | cmd( 24 | cfg.pipe_text_snippet_cmd.command, 25 | cfg.pipe_text_snippet_cmd.args, 26 | ) 27 | .stdin_bytes(string) 28 | .run()? 29 | .status, 30 | )) 31 | } 32 | } 33 | 34 | impl Display for TaskTreeWrapper { 35 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 36 | write!(f, "{}", self.data.0) 37 | } 38 | } 39 | impl TaskTreeWrapper { 40 | pub fn prepare_display(&mut self) { 41 | match self.data.0.root { 42 | NoteTaskItemTerm::Note(..) => unreachable!("note"), 43 | NoteTaskItemTerm::Cycle(..) => unreachable!("cycle"), 44 | NoteTaskItemTerm::TaskHint(..) => unreachable!("hint"), 45 | NoteTaskItemTerm::TaskMono(..) => unreachable!("task_mono"), 46 | NoteTaskItemTerm::Task(ref task_item) => { 47 | let result = task_item.skim_display(true); 48 | self.display_item = Some(AnsiString::parse(&result)); 49 | } 50 | }; 51 | } 52 | 53 | pub fn prepare_preview(&mut self) { 54 | let result = format!("{}", self); 55 | self.preview_item = Some(result); 56 | self.mono_preview_item = Some(format!("{}", self.data.1)); 57 | } 58 | } 59 | 60 | impl SkimItem for TaskTreeWrapper { 61 | /// The string to be used for matching (without color) 62 | fn text(&self) -> Cow { 63 | let input = match self.data.0.root { 64 | NoteTaskItemTerm::Note(..) => unreachable!("note"), 65 | NoteTaskItemTerm::Cycle(..) => unreachable!("cycle"), 66 | NoteTaskItemTerm::TaskHint(..) => unreachable!("hint"), 67 | NoteTaskItemTerm::TaskMono(..) => unreachable!("task_mono"), 68 | NoteTaskItemTerm::Task(ref task_item) => task_item.skim_display_mono(true), 69 | }; 70 | 71 | Cow::Owned(input) 72 | } 73 | 74 | /// The content to be displayed on the item list, could contain ANSI properties 75 | fn display<'a>(&'a self, _context: DisplayContext<'a>) -> AnsiString<'a> { 76 | if let Some(ref string) = self.display_item { 77 | string.clone() 78 | } else { 79 | AnsiString::parse("") 80 | } 81 | } 82 | 83 | /// Custom preview content, default to `ItemPreview::Global` which will use global preview 84 | /// setting(i.e. the command set by `preview` option) 85 | fn preview(&self, _context: PreviewContext) -> ItemPreview { 86 | if let Some(ref string) = self.preview_item { 87 | ItemPreview::AnsiText(string.clone()) 88 | } else { 89 | ItemPreview::AnsiText("".to_string()) 90 | } 91 | } 92 | } 93 | 94 | impl TaskTreeWrapper { 95 | pub fn toggle(self) -> Result<(), std::io::Error> { 96 | match self.data.0.root { 97 | NoteTaskItemTerm::Note(..) => unreachable!("note"), 98 | NoteTaskItemTerm::Cycle(..) => unreachable!("cycle"), 99 | NoteTaskItemTerm::TaskHint(..) => unreachable!("hint"), 100 | NoteTaskItemTerm::TaskMono(..) => unreachable!("task_mono"), 101 | NoteTaskItemTerm::Task(task_item) => { 102 | task_item.toggle()?; 103 | } 104 | }; 105 | Ok(()) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/link/parse.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path::PathBuf}; 2 | 3 | use crate::config::color::ColorScheme; 4 | use crate::lines::find_position; 5 | use crate::{config::SurfParsing, note::Note}; 6 | 7 | use super::Link; 8 | 9 | use comrak::nodes::{AstNode, NodeValue}; 10 | use comrak::{parse_document, Arena, ComrakOptions}; 11 | 12 | fn iter_nodes<'a, F>(node: &'a AstNode<'a>, counter: &mut usize, f: &mut F) 13 | where 14 | F: FnMut(&'a AstNode<'a>, &mut usize), 15 | { 16 | f(node, counter); 17 | for c in node.children() { 18 | iter_nodes(c, counter, f); 19 | } 20 | } 21 | 22 | impl 23 | From<( 24 | regex::Captures<'_>, 25 | PathBuf, 26 | String, 27 | &'_ regex::Regex, 28 | &str, 29 | ColorScheme, 30 | &'_ regex::Regex, 31 | )> for Link 32 | { 33 | fn from( 34 | value: ( 35 | regex::Captures<'_>, 36 | PathBuf, 37 | String, 38 | &'_ regex::Regex, 39 | &str, 40 | ColorScheme, 41 | &'_ regex::Regex, 42 | ), 43 | ) -> Self { 44 | let captures = value.0; 45 | let start = captures.name("description").unwrap(); 46 | let start = start.start(); 47 | let start = find_position(value.4, start); 48 | Link::new( 49 | captures["description"].to_string(), 50 | captures["url"].to_string(), 51 | value.1, 52 | value.2, 53 | value.3, 54 | value.6, 55 | start, 56 | value.5, 57 | ) 58 | } 59 | } 60 | 61 | #[allow(clippy::ptr_arg)] 62 | impl Link { 63 | fn reference_link_parse( 64 | note: &Note, 65 | result: &mut Vec, 66 | surf: &SurfParsing, 67 | file_path: &PathBuf, 68 | file_content: &str, 69 | color_scheme: ColorScheme, 70 | ) { 71 | for link in surf 72 | .markdown_reference_link_regex 73 | .0 74 | .captures_iter(file_content) 75 | { 76 | result.push( 77 | ( 78 | link, 79 | file_path.clone(), 80 | note.name(), 81 | &surf.url_regex.0, 82 | file_content, 83 | color_scheme, 84 | &surf.has_line_regex.0, 85 | ) 86 | .into(), 87 | ); 88 | } 89 | } 90 | 91 | fn ast_parse_code_blocks( 92 | note: &Note, 93 | result: &mut Vec, 94 | file_path: &PathBuf, 95 | file_content: &str, 96 | color_scheme: ColorScheme, 97 | ) { 98 | let arena = Arena::new(); 99 | 100 | let root = parse_document(&arena, file_content, &ComrakOptions::default()); 101 | 102 | let mut counter = 0; 103 | iter_nodes(root, &mut counter, &mut |node, counter| { 104 | let source_position = node.data.borrow().sourcepos; 105 | if let NodeValue::CodeBlock(ref block) = &node.data.borrow().value { 106 | let syntax_label = block.info.clone(); 107 | let code_block = block.literal.clone(); 108 | let description = if let Some(line) = code_block.lines().next() { 109 | line.to_string() 110 | } else { 111 | format!("snippet[{}]", counter) 112 | }; 113 | result.push(Link::new_code_block( 114 | file_path.clone(), 115 | note.name(), 116 | description, 117 | code_block, 118 | syntax_label, 119 | source_position, 120 | color_scheme, 121 | )); 122 | *counter += 1; 123 | } 124 | }); 125 | } 126 | pub fn parse( 127 | note: &Note, 128 | surf: &SurfParsing, 129 | color_scheme: ColorScheme, 130 | ) -> std::io::Result> { 131 | if let Some(file_path) = note.file_path() { 132 | let mut result = vec![]; 133 | let file_content = fs::read_to_string(file_path)?; 134 | 135 | Self::reference_link_parse( 136 | note, 137 | &mut result, 138 | surf, 139 | file_path, 140 | &file_content, 141 | color_scheme, 142 | ); 143 | Self::ast_parse_code_blocks(note, &mut result, file_path, &file_content, color_scheme); 144 | 145 | Ok(result) 146 | } else { 147 | Ok(vec![]) 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/skim/open.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use skim::{ 4 | prelude::{unbounded, Key, SkimOptionsBuilder}, 5 | Skim, SkimItemReceiver, SkimItemSender, 6 | }; 7 | 8 | use crate::{ 9 | config::{color::ColorScheme, ExternalCommands, SurfParsing}, 10 | database::SqliteAsyncHandle, 11 | highlight::MarkdownStatic, 12 | note::{DynResources, Note, PreviewType}, 13 | }; 14 | 15 | pub(crate) struct Iteration { 16 | db: SqliteAsyncHandle, 17 | items: Option>, 18 | multi: bool, 19 | preview_type: PreviewType, 20 | hint: String, 21 | external_commands: ExternalCommands, 22 | surf_parsing: SurfParsing, 23 | md_static: MarkdownStatic, 24 | 25 | color_scheme: ColorScheme, 26 | straight: bool, 27 | nested_threshold: usize, 28 | } 29 | 30 | impl Iteration { 31 | #[allow(clippy::too_many_arguments)] 32 | pub(crate) fn new( 33 | hint: String, 34 | items: Vec, 35 | db: SqliteAsyncHandle, 36 | multi: bool, 37 | preview_type: PreviewType, 38 | external_commands: ExternalCommands, 39 | surf_parsing: SurfParsing, 40 | md_static: MarkdownStatic, 41 | color_scheme: ColorScheme, 42 | straight: bool, 43 | nested_threshold: usize, 44 | ) -> Self { 45 | Self { 46 | items: Some(items), 47 | db, 48 | multi, 49 | preview_type, 50 | external_commands, 51 | surf_parsing, 52 | hint, 53 | md_static, 54 | color_scheme, 55 | straight, 56 | nested_threshold, 57 | } 58 | } 59 | 60 | pub(crate) async fn run(mut self) -> anyhow::Result { 61 | let items = self.items.take().unwrap(); 62 | 63 | let (tx, rx): (SkimItemSender, SkimItemReceiver) = unbounded(); 64 | 65 | let db = self.db; 66 | for mut note in items { 67 | let db_double = db.clone(); 68 | let ext_double = self.external_commands.clone(); 69 | let surf_parsing = self.surf_parsing.clone(); 70 | let tx_double = tx.clone(); 71 | 72 | tokio::task::spawn(async move { 73 | note.set_resources(DynResources { 74 | external_commands: ext_double, 75 | surf_parsing, 76 | preview_type: self.preview_type, 77 | preview_result: None, 78 | }); 79 | note.prepare_preview( 80 | &db_double, 81 | self.md_static, 82 | self.color_scheme, 83 | self.straight, 84 | self.nested_threshold, 85 | ) 86 | .await; 87 | let result = tx_double.send(Arc::new(note)); 88 | if result.is_err() { 89 | // eprintln!("{}",format!("{:?}", result).red()); 90 | } 91 | }); 92 | } 93 | drop(tx); 94 | 95 | let hint = self.hint; 96 | let out = tokio::task::spawn_blocking(move || { 97 | let hint = format!("({hint}) > "); 98 | let options = SkimOptionsBuilder::default() 99 | .height(Some("100%")) 100 | .preview(Some("")) 101 | .prompt(Some(&hint)) 102 | .preview_window(Some("up:80%")) 103 | .multi(self.multi) 104 | .bind(vec!["ctrl-c:abort", "Enter:accept", "ESC:abort"]) 105 | .build() 106 | .unwrap(); 107 | 108 | Skim::run_with(&options, Some(rx)) 109 | }) 110 | .await 111 | .unwrap(); 112 | 113 | if let Some(out) = out { 114 | let selected_items = out 115 | .selected_items 116 | .iter() 117 | .map(|selected_item| { 118 | (**selected_item) 119 | .as_any() 120 | .downcast_ref::() 121 | .unwrap() 122 | .to_owned() 123 | }) 124 | .collect::>(); 125 | 126 | match out.final_key { 127 | Key::Enter => { 128 | if let Some(item) = selected_items.first() { 129 | Ok(item.clone()) 130 | } else { 131 | Err(anyhow::anyhow!("no item selected")) 132 | } 133 | } 134 | Key::Ctrl('c') | Key::ESC => Err(anyhow::anyhow!( 135 | "user chose to abort current iteration of open cycle" 136 | )), 137 | _ => { 138 | unreachable!(); 139 | } 140 | } 141 | } else { 142 | Err(anyhow::anyhow!("skim internal errors")) 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/commands/checkmark.rs: -------------------------------------------------------------------------------- 1 | use futures::future::join_all; 2 | use syntect::easy::HighlightLines; 3 | 4 | use crate::{ 5 | config::{color::ColorScheme, keymap, ExternalCommands, SurfParsing}, 6 | database::{Database, SqliteAsyncHandle}, 7 | highlight::MarkdownStatic, 8 | note::{Note, NoteTaskItemTerm, PreviewType}, 9 | skim::checkmark::Action as TaskAction, 10 | skim::checkmark::Iteration as CheckmarkIteration, 11 | skim::explore::Action, 12 | task_item::{TaskItem, TaskTreeWrapper}, 13 | Jump, Yank, 14 | }; 15 | 16 | use super::explore::iteration; 17 | 18 | pub(crate) async fn exec( 19 | db: SqliteAsyncHandle, 20 | surf: SurfParsing, 21 | external_commands: ExternalCommands, 22 | md_static: MarkdownStatic, 23 | color_scheme: ColorScheme, 24 | bindings_map: keymap::checkmark::Bindings, 25 | explore_bindings_map: keymap::explore::Bindings, 26 | ) -> Result { 27 | let mut list = db.lock().await.list(md_static, color_scheme).await?; 28 | 29 | let nested_threshold = 1; 30 | let mut preview_type = PreviewType::TaskStructure; 31 | let note = loop { 32 | let (next_items, opened, preview_type_after) = iteration( 33 | db.clone(), 34 | list, 35 | &external_commands, 36 | &surf, 37 | preview_type, 38 | md_static, 39 | color_scheme, 40 | true, 41 | nested_threshold, 42 | explore_bindings_map.clone(), 43 | ) 44 | .await?; 45 | preview_type = preview_type_after; 46 | list = next_items; 47 | 48 | if let Some(Action::Open(opened)) = opened { 49 | break opened; 50 | } 51 | }; 52 | let _note = checkmark_note(note, &external_commands, &surf, md_static, bindings_map).await?; 53 | Ok("success".to_string()) 54 | } 55 | 56 | pub(crate) async fn checkmark_note( 57 | note: Note, 58 | external_commands: &ExternalCommands, 59 | surf: &SurfParsing, 60 | md_static: MarkdownStatic, 61 | bindings_map: keymap::checkmark::Bindings, 62 | ) -> Result { 63 | let mut next_tasks_window = None; 64 | let mut tasks = read_tasks_from_file(¬e, surf, md_static).await?; 65 | loop { 66 | let action = CheckmarkIteration::new(tasks, note.clone(), bindings_map.clone()).run()?; 67 | next_tasks_window = match action { 68 | TaskAction::Toggle(selected_tasks) => { 69 | for task in selected_tasks { 70 | task.toggle()?; 71 | } 72 | next_tasks_window 73 | } 74 | TaskAction::Open(task) => { 75 | let note_task_item_term = task.data.0.root; 76 | note_task_item_term.jump(external_commands.open.clone())?; 77 | 78 | None 79 | } 80 | TaskAction::Yank(task) => { 81 | task.yank(external_commands.open.clone())?; 82 | next_tasks_window 83 | } 84 | TaskAction::Widen => None, 85 | TaskAction::Narrow(start, end) => Some((start, end)), 86 | 87 | TaskAction::Return(note) => { 88 | return Ok(note); 89 | } 90 | }; 91 | tasks = match next_tasks_window { 92 | None => read_tasks_from_file(¬e, surf, md_static).await?, 93 | Some((start, end)) => { 94 | let all = read_tasks_from_file(¬e, surf, md_static).await?; 95 | all[start..end].to_vec() 96 | } 97 | }; 98 | } 99 | } 100 | 101 | async fn read_tasks_from_file( 102 | note: &Note, 103 | surf: &SurfParsing, 104 | md_static: MarkdownStatic, 105 | ) -> Result, anyhow::Error> { 106 | let mut highlighter = HighlightLines::new(md_static.1, md_static.2); 107 | let tasks = TaskItem::parse(note, surf, &mut highlighter, md_static)?; 108 | 109 | let tasks_stereo = NoteTaskItemTerm::parse(&tasks, false, false); 110 | let tasks_mono = NoteTaskItemTerm::parse(&tasks, false, true); 111 | let tasks = tasks_stereo 112 | .into_iter() 113 | .zip(tasks_mono.into_iter()) 114 | .collect::>(); 115 | 116 | let compute_display_jh = tasks 117 | .into_iter() 118 | .map(|element| { 119 | tokio::task::spawn(async move { 120 | let mut wrapper = TaskTreeWrapper { 121 | data: element, 122 | display_item: None, 123 | preview_item: None, 124 | mono_preview_item: None, 125 | }; 126 | 127 | wrapper.prepare_display(); 128 | wrapper.prepare_preview(); 129 | wrapper 130 | }) 131 | }) 132 | .collect::>(); 133 | let tasks = join_all(compute_display_jh).await; 134 | let tasks = tasks 135 | .into_iter() 136 | .map(|result| result.expect("we do not expect preview generation to panic")) 137 | .collect::>(); 138 | Ok(tasks) 139 | } 140 | -------------------------------------------------------------------------------- /USAGE.md: -------------------------------------------------------------------------------- 1 | # Help 2 | 3 | 4 | 5 | ``` 6 | mds -h 7 | ``` 8 | 9 | ``` 10 | meudeus v0.19.0 11 | a skim shredder for plain-text papers 12 | 13 | Usage: mds [OPTIONS] 14 | 15 | Commands: 16 | debug-cfg print Debug representtion of config 17 | init `initialize` .sqlite database in notes dir, specified by config 18 | note create a note [aliases: n] 19 | tag create a tag (note without file body) [aliases: t] 20 | select select note S, i.e. print its name to stdout 21 | link link 2 notes A -> B, selected twice in skim interface [aliases: l] 22 | unlink unlink 2 notes A -> B, selected twice in skim interface [aliases: ul] 23 | remove remove note R, selected in skim interface [aliases: rm] 24 | rename rename note R, selected in skim interface [aliases: mv] 25 | print print subgraph of notes and links reachable downwards from selected note P [aliases: p] 26 | explore explore notes by (backlinks) , (links forward) 27 | [aliases: ex] 28 | surf surf through all links and code snippets found downwards from selected note S 29 | [aliases: s] 30 | stack browse GLOBAL stack of notes [aliases: st] 31 | checkmark checkmark, toggle state TODO/DONE of multiple task items, found in a selected note C 32 | [aliases: k] 33 | help Print this message or the help of the given subcommand(s) 34 | 35 | Options: 36 | -c, --color whether color output should be forced 37 | -h, --help Print help 38 | -V, --version Print version 39 | ``` 40 | 41 | # Overview 42 | 43 | ## General 44 | 45 | 1. Any note can be linked to any number of other notes via a directed `->` link. 46 | 2. Note names are rendered as markdown in skim picker/preview. 47 | 3. A note having only a name, but devoid of earthly file body is also considered a note, but is called a tag instead. 48 | 4. Keybindings of most of secondary actions can be reconfigured in [config](./config.kdl). 49 | 5. A command can go through 1 or more modes during its dialogue, e.g. 50 | - `surf` command starts in `explore` mode. 51 | - after a user chooses a note, whose subtree he/she wants to explore for urls/code snippets, `surf` command switches to `surf` mode. 52 | - `surf` command stays in a `surf` mode loop for the note initially selected after a primary action with `Enter` or some secondary action has been selected for one of found urls/code snippets. 53 | 54 | ## Explore mode 55 | 56 | 1. All of `explore`, `surf` and `checkmark` commands start in `explore` mode. 57 | 2. `explore` command can switch to `surf` or `checkmark` mode and then back to `explore` mode. 58 | 3. `explore` command includes the functionality of most of other commands (`link`, `unlink` , `rename`, `delete`, `surf`, `checkmark`, etc), and is used as the only entrypoint for program's interface by its author. 59 | 4. In `explore` mode ` Ctrl-h ` (backlinks) and ` Ctrl-l ` (forwardlinks) bindings are available. 60 | 5. ` Ctrl-t ` keybinding may be used to toggle 61 | between **structural links** -> **structural task** -> **details** -> **(cycle)** preview of current note or 62 | note subgraph respectively. This rendered `p/print` command somewhat redundant. 63 | 64 | ## Surf mode 65 | 66 | 1. `surf` command/mode may be used for searching for all `[description](url/file_path/dir_path)` markdown links and `'''code_block'''` found downwards from a note S, selected for `surf`. 67 | 2. Destination in `[description](destination)` markdown links is matched against `world.surf-parsing.url-regex` regex in [config](./config.kdl). 68 | - If it matches, it's considered a url link. 69 | - Otherwise, it's considered local filesystem link, either absolute or relative (no `file://` protocol prefix required). 70 | - If `filesystem_link:37` matches `world.surf-parsing.file-dest-has-line-regex` regex in [config](./config.kdl) it's considered a `$FILE:$LINE` link. 71 | - Local filesystem link has any env variables replaced with their values, e.g. `$HOME/path/to/file` gets expanded to `/home/user/path/to/file`. 72 | 3. `'''code_block'''` description is parsed as the first line of `'''code_block'''`, comments `# bash comment` or `// C comment` may be used for informative descriptions. 73 | 4. Syntax in `'''code_block'''`can be hinted for highlight in preview by specifying tag \`\`\`syntax_tag, e.g. \`\`\`bash or \`\`\`javascript. 74 | 75 | ## Checkmark mode 76 | 77 | 1. `checkmark` command/mode may be used to parse out trees of `- [ ] description` task items and allows navigating/toggling them into `- [x] description` state. 78 | 79 | ## Stack mode 80 | 81 | 1. `stack` mode is a simple way to manage priorities of notes. 82 | 2. A note can be pushed to stack by *Alt-a* from `explore` mode of `explore` command. 83 | 3. A switch to `stack` mode from `explore` mode of `explore` command can be made by *Ctrl-a*. 84 | 4. By selecting a note with `Enter` in `stack` mode one returns to `explore` mode with the note selected. 85 | 5. In `stack` mode a note can be popped off stack with *Alt-p*. 86 | 6. Selected note can be moved to top of stack by *Alt-t*. 87 | 7. Currently only single `GLOBAL` stack is supported. It may be extended to multiple stacks in a future. 88 | 89 | # [Keybindings](./KEYBINDINGS.md) 90 | -------------------------------------------------------------------------------- /src/commands/stack.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use tokio::time::sleep; 4 | 5 | use crate::{ 6 | config::{color::ColorScheme, keymap, ExternalCommands, SurfParsing}, 7 | database::{Database, SqliteAsyncHandle}, 8 | highlight::MarkdownStatic, 9 | note::{Note, PreviewType}, 10 | print::format_two_tokens, 11 | skim::stack_sequential::Action, 12 | }; 13 | 14 | use super::explore::GLOBAL_STACK; 15 | 16 | pub(crate) async fn exec( 17 | db: SqliteAsyncHandle, 18 | input_items_from_explore: Vec, 19 | external_commands: ExternalCommands, 20 | surf_parsing: SurfParsing, 21 | md_static: MarkdownStatic, 22 | color_scheme: ColorScheme, 23 | bindings_map: keymap::stack::Bindings, 24 | ) -> Result { 25 | let notes = stack_select( 26 | db, 27 | input_items_from_explore, 28 | external_commands, 29 | surf_parsing, 30 | md_static, 31 | color_scheme, 32 | bindings_map, 33 | ) 34 | .await?; 35 | Ok(notes 36 | .first() 37 | .map(|note| note.name()) 38 | .unwrap_or("".to_string())) 39 | } 40 | 41 | pub(crate) async fn stack_select( 42 | db: SqliteAsyncHandle, 43 | input_items_from_explore: Vec, 44 | external_commands: ExternalCommands, 45 | surf_parsing: SurfParsing, 46 | md_static: MarkdownStatic, 47 | color_scheme: ColorScheme, 48 | bindings_map: keymap::stack::Bindings, 49 | ) -> Result, anyhow::Error> { 50 | let straight = true; 51 | let multi = true; 52 | let nested_threshold = 1; 53 | let mut preview_type = PreviewType::TaskStructure; 54 | let mut preselected_item = None; 55 | loop { 56 | let list = db 57 | .lock() 58 | .await 59 | .select_from_stack(GLOBAL_STACK, md_static, color_scheme) 60 | .await?; 61 | let action = crate::skim::stack_sequential::Iteration::new( 62 | format!("stack; {GLOBAL_STACK}"), 63 | input_items_from_explore.clone(), 64 | list, 65 | db.clone(), 66 | multi, 67 | preview_type, 68 | external_commands.clone(), 69 | surf_parsing.clone(), 70 | md_static, 71 | color_scheme, 72 | straight, 73 | nested_threshold, 74 | bindings_map.clone(), 75 | preselected_item.clone(), 76 | ) 77 | .run() 78 | .await?; 79 | 80 | match action { 81 | Action::Select(notes) => { 82 | println!( 83 | "{}", 84 | format_two_tokens( 85 | "selected ", 86 | ¬es 87 | .iter() 88 | .map(|note| note.name()) 89 | .collect::>() 90 | .join(", ") 91 | ) 92 | ); 93 | return Ok(notes); 94 | } 95 | Action::Return(notes) => { 96 | println!( 97 | "{}", 98 | format_two_tokens("returning to previous", "selection in explore") 99 | ); 100 | return Ok(notes); 101 | } 102 | Action::TogglePreview => { 103 | preselected_item = None; 104 | preview_type = preview_type.toggle(); 105 | } 106 | Action::Pop(note) => { 107 | preselected_item = None; 108 | let name = note.name(); 109 | db.lock() 110 | .await 111 | .pop_note_from_stack(GLOBAL_STACK, &name) 112 | .await?; 113 | println!( 114 | "{}", 115 | format_two_tokens("popped ", &format!("{name} from {GLOBAL_STACK}")) 116 | ); 117 | sleep(Duration::new(0, 500_000_000)).await; 118 | } 119 | Action::MoveTopmost(note) => { 120 | preselected_item = None; 121 | let name = note.name(); 122 | db.lock().await.move_to_topmost(GLOBAL_STACK, &name).await?; 123 | println!( 124 | "{}", 125 | format_two_tokens("moved to topmost ", &format!("{name} in {GLOBAL_STACK}")) 126 | ); 127 | sleep(Duration::new(0, 500_000_000)).await; 128 | } 129 | Action::SwapWithAbove(note) => { 130 | let name = note.name(); 131 | db.lock().await.swap_with_above(GLOBAL_STACK, &name).await?; 132 | preselected_item = Some(note.name()); 133 | println!( 134 | "{}", 135 | format_two_tokens("swapped with above ", &format!("{name} in {GLOBAL_STACK}")) 136 | ); 137 | } 138 | Action::SwapWithBelow(note) => { 139 | let name = note.name(); 140 | db.lock().await.swap_with_below(GLOBAL_STACK, &name).await?; 141 | preselected_item = Some(note.name()); 142 | println!( 143 | "{}", 144 | format_two_tokens("swapped with below ", &format!("{name} in {GLOBAL_STACK}")) 145 | ); 146 | } 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/skim/checkmark.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::Arc}; 2 | 3 | use skim::{ 4 | prelude::{unbounded, Key, SkimOptionsBuilder}, 5 | Skim, SkimItemReceiver, SkimItemSender, 6 | }; 7 | 8 | use crate::{ 9 | config::keymap, 10 | note::{Note, NoteTaskItemTerm}, 11 | task_item::TaskTreeWrapper, 12 | }; 13 | 14 | #[allow(clippy::large_enum_variant)] 15 | pub enum Action { 16 | Toggle(Vec), 17 | Open(TaskTreeWrapper), 18 | Yank(TaskTreeWrapper), 19 | Widen, 20 | Narrow(usize, usize), 21 | Return(Note), 22 | } 23 | 24 | pub(crate) struct Iteration { 25 | items: Option>, 26 | return_note: Note, 27 | bindings_map: keymap::checkmark::Bindings, 28 | } 29 | impl Iteration { 30 | pub(crate) fn new( 31 | items: Vec, 32 | return_note: Note, 33 | bindings_map: keymap::checkmark::Bindings, 34 | ) -> Self { 35 | Self { 36 | items: Some(items), 37 | return_note, 38 | bindings_map, 39 | } 40 | } 41 | 42 | pub(crate) fn run(mut self) -> anyhow::Result { 43 | let keys_descriptors = self.bindings_map.keys_descriptors(); 44 | let mut bindings = vec!["ctrl-c:abort", "ESC:abort", "Enter:accept"]; 45 | bindings.extend( 46 | keys_descriptors 47 | .into_iter() 48 | .map(|element| &*(Box::::leak(element.into_boxed_str()))), 49 | ); 50 | let items = self.items.take().unwrap(); 51 | let note_hint = format!("(checkmark: {}) > ", self.return_note.name()); 52 | let options = SkimOptionsBuilder::default() 53 | .height(Some("100%")) 54 | .preview(Some("")) 55 | .prompt(Some(¬e_hint)) 56 | .preview_window(Some("right:65%")) 57 | .multi(true) 58 | .bind(bindings) 59 | .build()?; 60 | 61 | let (tx, rx): (SkimItemSender, SkimItemReceiver) = unbounded(); 62 | 63 | let _jh = tokio::task::spawn_blocking(move || { 64 | for link in items.into_iter().rev() { 65 | let _result = tx.send(Arc::new(link)); 66 | // if result.is_err() { 67 | // eprintln!("{}", format!("{:?}", result).red()); 68 | // } 69 | } 70 | }); 71 | 72 | let bindings_map: HashMap = 73 | (&self.bindings_map).into(); 74 | if let Some(out) = Skim::run_with(&options, Some(rx)) { 75 | let selected_items = out 76 | .selected_items 77 | .iter() 78 | .map(|selected_item| { 79 | (**selected_item) 80 | .as_any() 81 | .downcast_ref::() 82 | .unwrap() 83 | .clone() 84 | }) 85 | .collect::>(); 86 | 87 | let action = match out.final_key { 88 | Key::Ctrl('c') | Key::ESC => { 89 | return Err(anyhow::anyhow!( 90 | "user chose to abort current iteration of checkmark cycle" 91 | )) 92 | } 93 | Key::Enter => { 94 | return Ok(Action::Toggle(selected_items)); 95 | } 96 | key @ Key::Ctrl(..) | key @ Key::Alt(..) => bindings_map.get(&key).cloned(), 97 | _ => { 98 | unreachable!(); 99 | } 100 | }; 101 | let Some(action) = action else { 102 | unreachable!("an unspecified keybinding isn't expected to pick None from Hashmap"); 103 | }; 104 | 105 | match action { 106 | keymap::checkmark::Action::JumpToTask => { 107 | let first = selected_items.first().expect("non empty"); 108 | Ok(Action::Open(first.clone())) 109 | } 110 | keymap::checkmark::Action::CopyTaskSubtree => { 111 | let first = selected_items.first().expect("non empty"); 112 | Ok(Action::Yank(first.clone())) 113 | } 114 | keymap::checkmark::Action::WidenContext => Ok(Action::Widen), 115 | keymap::checkmark::Action::NarrowContext => { 116 | let first = selected_items.first().expect("non empty"); 117 | let (start, end) = match first.data.0.root { 118 | NoteTaskItemTerm::Note(..) => unreachable!("note"), 119 | NoteTaskItemTerm::Cycle(..) => unreachable!("cycle"), 120 | NoteTaskItemTerm::TaskHint(..) => unreachable!("hint"), 121 | NoteTaskItemTerm::Task(ref task) | NoteTaskItemTerm::TaskMono(ref task) => { 122 | let next_index = match task.next_index { 123 | Some(next_index) => next_index, 124 | None => task.self_index + 1, 125 | }; 126 | (task.self_index, next_index) 127 | } 128 | }; 129 | Ok(Action::Narrow(start, end)) 130 | } 131 | keymap::checkmark::Action::ReturnToExplore => Ok(Action::Return(self.return_note)), 132 | } 133 | } else { 134 | Err(anyhow::anyhow!("skim internal errors")) 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/skim/surf.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, fmt::Display, sync::Arc}; 2 | 3 | use skim::{ 4 | prelude::{unbounded, Key, SkimOptionsBuilder}, 5 | Skim, SkimItemReceiver, SkimItemSender, 6 | }; 7 | 8 | use crate::{ 9 | config::{color::ColorScheme, keymap, ExternalCommands}, 10 | highlight::MarkdownStatic, 11 | link::Link, 12 | note::Note, 13 | }; 14 | 15 | #[allow(clippy::large_enum_variant)] 16 | #[derive(Debug)] 17 | pub enum Action { 18 | Jump(Link), 19 | Open(Link), 20 | OpenXDG(Link), 21 | Return(Note), 22 | } 23 | 24 | impl Display for Action { 25 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 26 | match self { 27 | Self::Jump(link) => write!(f, "jump : {}", link), 28 | Self::Open(link) => write!(f, "open : {}", link), 29 | Self::OpenXDG(link) => write!(f, "open xdg : {}", link), 30 | Self::Return(note) => write!(f, "return to explore : {}", note), 31 | } 32 | } 33 | } 34 | pub(crate) struct Iteration { 35 | items: Option>, 36 | multi: bool, 37 | external_commands: ExternalCommands, 38 | return_note: Note, 39 | md_static: MarkdownStatic, 40 | color_scheme: ColorScheme, 41 | bindings_map: keymap::surf::Bindings, 42 | } 43 | 44 | impl Iteration { 45 | pub(crate) fn new( 46 | items: Vec, 47 | multi: bool, 48 | external_commands: ExternalCommands, 49 | return_note: Note, 50 | md_static: MarkdownStatic, 51 | color_scheme: ColorScheme, 52 | bindings_map: keymap::surf::Bindings, 53 | ) -> Self { 54 | Self { 55 | items: Some(items), 56 | multi, 57 | external_commands, 58 | return_note, 59 | md_static, 60 | color_scheme, 61 | bindings_map, 62 | } 63 | } 64 | 65 | pub(crate) async fn run(mut self) -> anyhow::Result { 66 | let items = self.items.take().unwrap(); 67 | 68 | let (tx, rx): (SkimItemSender, SkimItemReceiver) = unbounded(); 69 | let note_hint = self.return_note.name(); 70 | for mut link in items { 71 | let tx_double = tx.clone(); 72 | let ext_cmds_double = self.external_commands.clone(); 73 | tokio::task::spawn(async move { 74 | link.prepare_display(); 75 | link.prepare_preview(&ext_cmds_double.preview, self.md_static, self.color_scheme); 76 | let _result = tx_double.send(Arc::new(link)); 77 | // if result.is_err() { 78 | // eprintln!("{}", format!("{:?}", result).red()); 79 | // } 80 | }); 81 | } 82 | 83 | drop(tx); 84 | 85 | let keys_descriptors = self.bindings_map.keys_descriptors(); 86 | let out = tokio::task::spawn_blocking({ 87 | let mut bindings = vec!["ctrl-c:abort", "ESC:abort", "Enter:accept"]; 88 | bindings.extend( 89 | keys_descriptors 90 | .into_iter() 91 | .map(|element| &*(Box::::leak(element.into_boxed_str()))), 92 | ); 93 | let note_hint = format!("(surf: {}) > ", note_hint); 94 | move || { 95 | let options = SkimOptionsBuilder::default() 96 | .height(Some("100%")) 97 | .preview(Some("")) 98 | .prompt(Some(¬e_hint)) 99 | .preview_window(Some("up:50%")) 100 | .multi(self.multi) 101 | .bind(bindings) 102 | .build() 103 | .unwrap(); 104 | Skim::run_with(&options, Some(rx)) 105 | } 106 | }) 107 | .await 108 | .unwrap(); 109 | 110 | let bindings_map: HashMap = 111 | (&self.bindings_map).into(); 112 | if let Some(out) = out { 113 | let selected_items = out 114 | .selected_items 115 | .iter() 116 | .map(|selected_item| { 117 | (**selected_item) 118 | .as_any() 119 | .downcast_ref::() 120 | .unwrap() 121 | .to_owned() 122 | }) 123 | .collect::>(); 124 | 125 | let action = match out.final_key { 126 | Key::Ctrl('c') | Key::ESC => { 127 | return Err(anyhow::anyhow!( 128 | "user chose to abort current iteration of surf cycle" 129 | )) 130 | } 131 | Key::Enter => { 132 | if let Some(item) = selected_items.first() { 133 | return Ok(Action::Open(item.clone())); 134 | } else { 135 | return Err(anyhow::anyhow!("no item selected")); 136 | } 137 | } 138 | key @ Key::Ctrl(..) | key @ Key::Alt(..) => bindings_map.get(&key).cloned(), 139 | 140 | _ => { 141 | unreachable!(); 142 | } 143 | }; 144 | let Some(action) = action else { 145 | unreachable!("an unspecified keybinding isn't expected to pick None from Hashmap"); 146 | }; 147 | match action { 148 | keymap::surf::Action::OpenXDG => { 149 | if let Some(item) = selected_items.first() { 150 | Ok(Action::OpenXDG(item.clone())) 151 | } else { 152 | Err(anyhow::anyhow!("no item selected")) 153 | } 154 | } 155 | keymap::surf::Action::JumpToLinkOrSnippet => { 156 | if let Some(item) = selected_items.first() { 157 | Ok(Action::Jump(item.clone())) 158 | } else { 159 | Err(anyhow::anyhow!("no item selected")) 160 | } 161 | } 162 | keymap::surf::Action::ReturnToExplore => Ok(Action::Return(self.return_note)), 163 | } 164 | } else { 165 | Err(anyhow::anyhow!("skim internal errors")) 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/note/links_term_tree.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashSet, fmt::Display}; 2 | 3 | use async_recursion::async_recursion; 4 | use bidir_termtree::{Down, Tree, Up}; 5 | use colored::Colorize; 6 | 7 | use sqlx::Result as SqlxResult; 8 | 9 | use crate::{ 10 | config::{color::ColorScheme, ExternalCommands, SurfParsing}, 11 | database::{Database, SqliteAsyncHandle}, 12 | highlight::MarkdownStatic, 13 | link::Link, 14 | }; 15 | 16 | use super::Note; 17 | 18 | #[allow(clippy::large_enum_variant)] 19 | #[derive(Clone)] 20 | pub enum NoteLinkTerm { 21 | Note(Note), 22 | Link(Link), 23 | LinkHint(bool, usize, ColorScheme), 24 | Cycle(String, ColorScheme), 25 | } 26 | 27 | impl Display for NoteLinkTerm { 28 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 29 | match self { 30 | Self::Note(note) => { 31 | write!(f, "{}", note) 32 | } 33 | 34 | Self::Link(link) => { 35 | write!(f, "{}", link.skim_display()) 36 | } 37 | 38 | Self::LinkHint(only_hint, num, color) => { 39 | if *only_hint { 40 | let c = color.links.unlisted; 41 | write!( 42 | f, 43 | "{}", 44 | format!("{num} links unlisted ").truecolor(c.0.r, c.0.g, c.0.b) 45 | ) 46 | } else { 47 | let c = color.links.unlisted; 48 | write!( 49 | f, 50 | "{}", 51 | format!("{num} links ").truecolor(c.0.r, c.0.g, c.0.b) 52 | ) 53 | } 54 | } 55 | Self::Cycle(cycle, color) => { 56 | let c = color.links.cycle; 57 | write!(f, "⟳ {}", cycle.truecolor(c.0.r, c.0.g, c.0.b)) 58 | } 59 | } 60 | } 61 | } 62 | 63 | impl Note { 64 | #[allow(clippy::too_many_arguments)] 65 | #[async_recursion] 66 | pub async fn construct_link_term_tree( 67 | &self, 68 | level: usize, 69 | nested_threshold: usize, 70 | mut all_reachable: HashSet, 71 | external_commands: ExternalCommands, 72 | surf_parsing: SurfParsing, 73 | db: SqliteAsyncHandle, 74 | md_static: MarkdownStatic, 75 | color_scheme: ColorScheme, 76 | ) -> SqlxResult<(Tree, HashSet)> { 77 | let straight = true; 78 | let mut tree = Tree::new(NoteLinkTerm::Note(self.clone())); 79 | all_reachable.insert(self.clone()); 80 | 81 | let links = Link::parse(self, &surf_parsing, color_scheme)?; 82 | 83 | if !links.is_empty() { 84 | if level >= nested_threshold { 85 | tree.push(NoteLinkTerm::LinkHint(true, links.len(), color_scheme)); 86 | } else { 87 | let hint = NoteLinkTerm::LinkHint(false, links.len(), color_scheme); 88 | let mut hint_tree = Tree::new(hint); 89 | for link in links { 90 | hint_tree.push(NoteLinkTerm::Link(link)); 91 | } 92 | tree.push(hint_tree); 93 | } 94 | } 95 | 96 | let forward_links = db 97 | .lock() 98 | .await 99 | .find_links_from(&self.name(), md_static, color_scheme, straight) 100 | .await?; 101 | 102 | for next in forward_links.into_iter().rev() { 103 | if all_reachable.contains(&next) { 104 | tree.push(Tree::new(NoteLinkTerm::Cycle(next.name(), color_scheme))); 105 | } else { 106 | let (next_tree, roundtrip_reachable) = next 107 | .construct_link_term_tree( 108 | level + 1, 109 | nested_threshold, 110 | all_reachable, 111 | external_commands.clone(), 112 | surf_parsing.clone(), 113 | db.clone(), 114 | md_static, 115 | color_scheme, 116 | ) 117 | .await?; 118 | all_reachable = roundtrip_reachable; 119 | tree.push(next_tree); 120 | } 121 | } 122 | 123 | Ok((tree, all_reachable)) 124 | } 125 | 126 | #[allow(clippy::too_many_arguments)] 127 | #[async_recursion] 128 | pub async fn construct_link_term_tree_up( 129 | &self, 130 | level: usize, 131 | nested_threshold: usize, 132 | mut all_reachable: HashSet, 133 | external_commands: ExternalCommands, 134 | surf_parsing: SurfParsing, 135 | db: SqliteAsyncHandle, 136 | md_static: MarkdownStatic, 137 | color_scheme: ColorScheme, 138 | ) -> SqlxResult<(Tree, HashSet)> { 139 | let straight = false; 140 | let mut tree = Tree::new(NoteLinkTerm::Note(self.clone())); 141 | all_reachable.insert(self.clone()); 142 | 143 | let links = Link::parse(self, &surf_parsing, color_scheme)?; 144 | 145 | if !links.is_empty() { 146 | if level >= nested_threshold { 147 | tree.push(NoteLinkTerm::LinkHint(true, links.len(), color_scheme)); 148 | } else { 149 | let hint = NoteLinkTerm::LinkHint(false, links.len(), color_scheme); 150 | let mut hint_tree = Tree::new(hint); 151 | for link in links { 152 | hint_tree.push(NoteLinkTerm::Link(link)); 153 | } 154 | tree.push(hint_tree); 155 | } 156 | } 157 | 158 | let forward_links = db 159 | .lock() 160 | .await 161 | .find_links_from(&self.name(), md_static, color_scheme, straight) 162 | .await?; 163 | 164 | for next in forward_links.into_iter().rev() { 165 | if all_reachable.contains(&next) { 166 | tree.push(Tree::new(NoteLinkTerm::Cycle(next.name(), color_scheme))); 167 | } else { 168 | let (next_tree, roundtrip_reachable) = next 169 | .construct_link_term_tree_up( 170 | level + 1, 171 | nested_threshold, 172 | all_reachable, 173 | external_commands.clone(), 174 | surf_parsing.clone(), 175 | db.clone(), 176 | md_static, 177 | color_scheme, 178 | ) 179 | .await?; 180 | all_reachable = roundtrip_reachable; 181 | tree.push(next_tree); 182 | } 183 | } 184 | 185 | Ok((tree, all_reachable)) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/task_item.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path::PathBuf}; 2 | 3 | use colored::Colorize; 4 | use regex::Regex; 5 | use syntect::easy::HighlightLines; 6 | 7 | use crate::{ 8 | config::SurfParsing, 9 | highlight::{highlight, MarkdownStatic}, 10 | note::Note, 11 | }; 12 | mod skim_item; 13 | 14 | pub use skim_item::TaskTreeWrapper; 15 | 16 | #[derive(Clone, Debug, PartialEq, Eq)] 17 | pub struct TaskItem { 18 | pub file_name: PathBuf, 19 | pub title: String, 20 | pub title_markdown: String, 21 | pub completed: bool, 22 | pub nested_level: usize, 23 | pub checkmark_offsets_in_string: std::ops::Range, 24 | pub self_index: usize, 25 | pub next_index: Option, 26 | } 27 | 28 | impl TaskItem { 29 | fn parse_capture( 30 | value: (PathBuf, regex::Captures<'_>, usize), 31 | highlighter: &mut HighlightLines, 32 | md_static: MarkdownStatic, 33 | ) -> Self { 34 | let title = value.1.name("task_text").unwrap(); 35 | let checkmark = value.1.name("checkmark").unwrap(); 36 | let completed = checkmark.as_str() == "x"; 37 | let whitespace = value.1.name("whitespace").unwrap().as_str(); 38 | let nested_level = whitespace.len() / 2; 39 | let checkmark_offsets_in_string = checkmark.start()..checkmark.end(); 40 | 41 | let title_markdown = format!( 42 | "{} {}", 43 | highlight(title.as_str(), highlighter, md_static), 44 | " ".truecolor(0, 0, 0) 45 | ); 46 | Self { 47 | file_name: value.0, 48 | nested_level, 49 | completed, 50 | title: title.as_str().to_string(), 51 | checkmark_offsets_in_string, 52 | self_index: value.2, 53 | next_index: None, 54 | title_markdown, 55 | } 56 | } 57 | #[allow(clippy::ptr_arg)] 58 | fn parse_string( 59 | file_name: &PathBuf, 60 | input: &str, 61 | regex: &Regex, 62 | highlighter: &mut HighlightLines, 63 | md_static: MarkdownStatic, 64 | ) -> Vec { 65 | let mut result = vec![]; 66 | 67 | for (index, capture) in regex.captures_iter(input).enumerate() { 68 | result.push(Self::parse_capture( 69 | (file_name.clone(), capture, index), 70 | highlighter, 71 | md_static, 72 | )); 73 | } 74 | result 75 | } 76 | pub fn parse( 77 | note: &Note, 78 | surf: &SurfParsing, 79 | highlighter: &mut HighlightLines, 80 | md_static: MarkdownStatic, 81 | ) -> std::io::Result> { 82 | if let Some(file_path) = note.file_path() { 83 | let file_content = fs::read_to_string(file_path)?; 84 | let result = Self::parse_string( 85 | file_path, 86 | &file_content, 87 | &surf.task_item_regex.0, 88 | highlighter, 89 | md_static, 90 | ); 91 | 92 | Ok(result) 93 | } else { 94 | Ok(vec![]) 95 | } 96 | } 97 | } 98 | 99 | impl TaskItem { 100 | pub fn skim_display(&self, indented: bool) -> String { 101 | let indent = if indented { 102 | let mut string = String::new(); 103 | for _i in 0..self.nested_level { 104 | string.push_str(" "); 105 | } 106 | string 107 | } else { 108 | "".to_string() 109 | }; 110 | let symbol = if self.completed { 111 | "✓".truecolor(0, 255, 0).to_string() 112 | } else { 113 | " ".to_string() 114 | }; 115 | let input = format!("{}[{}] {}", indent, symbol, self.title_markdown,); 116 | input 117 | } 118 | 119 | pub fn skim_display_mono(&self, indented: bool) -> String { 120 | let indent = if indented { 121 | let mut string = String::new(); 122 | for _i in 0..self.nested_level { 123 | string.push_str(" "); 124 | } 125 | string 126 | } else { 127 | "".to_string() 128 | }; 129 | let symbol = if self.completed { 130 | "✓".to_string() 131 | } else { 132 | " ".to_string() 133 | }; 134 | let input = format!("{}[{}] {} {}", indent, symbol, self.title, " "); 135 | input 136 | } 137 | 138 | pub fn toggle(mut self) -> std::io::Result<()> { 139 | let prev = self.skim_display(false); 140 | self.completed = !self.completed; 141 | let next = self.skim_display(false); 142 | println!("{} -> {}", prev, next); 143 | 144 | let mut file_content = fs::read_to_string(&self.file_name)?; 145 | let target = if self.completed { "x" } else { " " }; 146 | file_content.replace_range(self.checkmark_offsets_in_string, target); 147 | fs::write(&self.file_name, file_content) 148 | } 149 | } 150 | 151 | #[cfg(test)] 152 | mod tests { 153 | use std::path::PathBuf; 154 | 155 | use regex::Regex; 156 | use syntect::easy::HighlightLines; 157 | 158 | use crate::highlight::static_markdown_syntax; 159 | 160 | use super::TaskItem; 161 | 162 | static TEST_STR: &str = r#" 163 | - [x] move `construct_term_tree` to a separate module on note.rs 164 | - [ ] create `TaskItem` struct 165 | - [x] test parsing of this snippet 166 | - [x] it's a very very meta test, depicting what has actually benn happening 167 | - [x] in development 168 | - [ ] implement `skim_display` for it 169 | - [ ] allow starring of subtasks as in [mdt](https://github.com/basilioss/mdt) 170 | - [ ] command is called `mds chm`, short for `checkmark` 171 | "#; 172 | 173 | #[test] 174 | fn test_tasks_items_parsing() { 175 | let regex = 176 | Regex::new(r#"(?P( )*)- \[(?P[x ])\]\s+(?P.+)"#) 177 | .unwrap(); 178 | 179 | let md_static = static_markdown_syntax(None); 180 | let mut highlighter = HighlightLines::new(md_static.1, md_static.2); 181 | let list = TaskItem::parse_string( 182 | &PathBuf::from("./tmp.rs"), 183 | TEST_STR, 184 | ®ex, 185 | &mut highlighter, 186 | md_static, 187 | ); 188 | assert_eq!(8, list.len()); 189 | assert_eq!( 190 | list[4].title, 191 | "in development ".to_string() 192 | ); 193 | assert!(list[4].title_markdown.contains("\u{1b}[48;2;45;45;45m\u{1b}[38;2;211;208;200min development \u{1b}[48;2;45;45;45m\u{1b}[38;2;211;208;200m<\u{1b}[48;2;45;45;45m\u{1b}[38;2;211;208;200mTue Mar 21 08:20:37 PM EET 2023>")); 194 | 195 | assert_eq!(list[4].nested_level, 3); 196 | assert!(list[4].completed); 197 | assert_eq!(list[4].file_name, Into::::into("./tmp.rs")); 198 | assert_eq!(list[4].checkmark_offsets_in_string, 362..363); 199 | assert_eq!(list[4].self_index, 4); 200 | assert_eq!(list[4].next_index, None); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [unreleased] 6 | 7 | ### Miscellaneous Tasks 8 | 9 | - Fmt 10 | 11 | ## [0.19.0] - 2024-03-09 12 | 13 | ### Features 14 | 15 | - Replace `hx` with `$EDITOR` in config 16 | 17 | ### Miscellaneous Tasks 18 | 19 | - Changelog update 20 | - . 21 | 22 | ## [0.18.6] - 2023-10-05 23 | 24 | ### Features 25 | 26 | - Multiple select delete 27 | 28 | ### Miscellaneous Tasks 29 | 30 | - Fmt + vers update 31 | 32 | ## [0.18.5] - 2023-06-17 33 | 34 | ### Documentation 35 | 36 | - Add note about env-substitute crate usage 37 | 38 | ### Features 39 | 40 | - Added `select` flag to `explore` command as in `mds explore --select 'snippet | rust' --select syntaxes --select lib` 41 | 42 | ## [0.18.4] - 2023-06-15 43 | 44 | ### Documentation 45 | 46 | - Update `stack` mode with `deselect_all` keybinding 47 | 48 | ### Features 49 | 50 | - [**breaking**] Add move_up_by_one and move_down_by_one actions 51 | - [**breaking**] Add deselect_all binding to stack mode to clean selection of items 52 | - Return multiple selection from stack, remove unnecessary narrowing of skim selection after some of secondary actions in explore mode 53 | 54 | ### Miscellaneous Tasks 55 | 56 | - Add lint to ci 57 | - Fix or supress clippy lints 58 | - Add prepush check to justfile 59 | 60 | ## [0.18.3] - 2023-06-08 61 | 62 | ### Miscellaneous Tasks 63 | 64 | - Add test and build to ci 65 | 66 | ## [0.18.2] - 2023-06-07 67 | 68 | ### Features 69 | 70 | - Keybinding to quit to `explore` mode from `stack` without changes 71 | 72 | ## [0.18.1] - 2023-06-06 73 | 74 | ### Features 75 | 76 | - Implement miette Reports for config errors 77 | 78 | ## [0.18.0] - 2023-05-28 79 | 80 | ### Features 81 | 82 | - [**breaking**] Implement config of most of keys 83 | 84 | ### Miscellaneous Tasks 85 | 86 | - Update changelog 87 | 88 | ## [0.17.2] - 2023-05-15 89 | 90 | ### Features 91 | 92 | - Implement open with `xdg-open` or `dio` via *C-o* binding from `surf` and `explore`; (`opener` crate) 93 | 94 | ### Miscellaneous Tasks 95 | 96 | - . 97 | - Update readme 98 | 99 | ## [0.17.1] - 2023-04-29 100 | 101 | ### Features 102 | 103 | - Change direction of tree printed on toggling direction of links 104 | 105 | ### Miscellaneous Tasks 106 | 107 | - Tag 108 | 109 | ## [0.17.0] - 2023-04-29 110 | 111 | ### Features 112 | 113 | - [**breaking**] Parse $FILE:$LINE destinations of [description](destination) links; change in config 114 | 115 | ### Miscellaneous Tasks 116 | 117 | - V0.16.0 change mentioned 118 | 119 | ## [0.16.0] - 2023-04-24 120 | 121 | ### Features 122 | 123 | - Implement pushing notes to `GLOBAL` stack and switching to viewing it 124 | 125 | ### Miscellaneous Tasks 126 | 127 | - Remove reduntant file 128 | - Replace changelog image with text 129 | - Replace changelog image with text 130 | - Update readme with short description of `stack` mode 131 | 132 | ## [0.15.4] - 2023-04-20 133 | 134 | ### Features 135 | 136 | - Increase/decrease unlisted items nesting threshold by keybindings 137 | 138 | ### Miscellaneous Tasks 139 | 140 | - Fix doc broken link 141 | 142 | ## [0.15.3] - 2023-04-19 143 | 144 | ### Features 145 | 146 | - Add grouping tree hint of self.items 147 | 148 | ## [0.15.2] - 2023-04-18 149 | 150 | ### Features 151 | 152 | - Narrow notes selection list to selected item(s) 153 | 154 | ## [0.15.1] - 2023-04-18 155 | 156 | ### Features 157 | 158 | - Splice action 159 | 160 | ## [0.15.0] - 2023-04-18 161 | 162 | ### Features 163 | 164 | - Add action **Alt-f** to toggle direction of links to `explore` command 165 | 166 | ## [0.14.4] - 2023-04-17 167 | 168 | ### Features 169 | 170 | - Integrate env-substitute crate for [markdow](link) file/dir destinations 171 | 172 | ### Miscellaneous Tasks 173 | 174 | - Tiniest additions to doc 175 | 176 | ## [0.14.3] - 2023-04-16 177 | 178 | ### Miscellaneous Tasks 179 | 180 | - Small corrections to doc 181 | 182 | ## [0.14.2] - 2023-04-15 183 | 184 | ### Miscellaneous Tasks 185 | 186 | - Update readme 187 | - Bump published version to propagate readme 188 | - Fix typos and ambiguities in readme 189 | 190 | ## [0.14.1] - 2023-04-15 191 | 192 | ### Features 193 | 194 | - Extract elements' colors to config 195 | 196 | ### Miscellaneous Tasks 197 | 198 | - Fmt cfg 199 | 200 | ## [0.14.0] - 2023-04-15 201 | 202 | ### Features 203 | 204 | - `world.color.theme` config field 205 | 206 | ## [0.13.7] - 2023-04-14 207 | 208 | ### Bug Fixes 209 | 210 | - Alpabetic order of forward links 211 | 212 | ### Features 213 | 214 | - Invoke `surf` and `checkmark` with bindings 215 | 216 | ### Miscellaneous Tasks 217 | 218 | - Fixate cyan for tags in truecolor escapes 219 | - Change skim position upwards 220 | - Fixate red for special tags 221 | - Move checkmark surf preview back to the right 222 | 223 | ## [0.13.5] - 2023-04-09 224 | 225 | ### Features 226 | 227 | - Delete note/tag from `explore` interface 228 | 229 | ## [0.13.3] - 2023-04-09 230 | 231 | ### Features 232 | 233 | - Link forward links from `explore` interface 234 | 235 | ## [0.13.2] - 2023-04-09 236 | 237 | ### Features 238 | 239 | - Improve `markdown` rendering performace 240 | 241 | ## [0.13.1] - 2023-04-08 242 | 243 | ### Features 244 | 245 | - Rename note from explore by 246 | 247 | ### Miscellaneous Tasks 248 | 249 | - Pics 250 | 251 | ## [0.13.0] - 2023-04-08 252 | 253 | ### Features 254 | 255 | - Widen binding; markdown syntax highlight for notes' names 256 | 257 | ## [0.12.3] - 2023-04-08 258 | 259 | ### Features 260 | 261 | - Narrow and widen via and bindings 262 | 263 | ### Miscellaneous Tasks 264 | 265 | - Roady 266 | - Reduce pause after surf action 267 | 268 | ## [0.12.2] - 2023-04-04 269 | 270 | ### Features 271 | 272 | - Copy selected task_item subtree to clipboard by 273 | 274 | ### Miscellaneous Tasks 275 | 276 | - Add hints of current stage to skim prompt 277 | 278 | ## [0.12.1] - 2023-04-02 279 | 280 | ### Features 281 | 282 | - Jump to selected link/snippet by binding 283 | 284 | ## [0.12.0] - 2023-04-02 285 | 286 | ### Features 287 | 288 | - Jump to selected item by binding 289 | 290 | ## [0.11.6] - 2023-04-01 291 | 292 | ### Bug Fixes 293 | 294 | - Improve links rendering performance in surf command 295 | 296 | ## [0.11.5] - 2023-04-01 297 | 298 | ### Bug Fixes 299 | 300 | - Add crate for `wl-copy` 301 | - Make skim preview not staggering for tasks 302 | - Make note preview cached 303 | - Remove lazy preview evaluation and caching of preview 304 | - Order of self::tasks and tasks of subnotes 305 | - Improve tasks preview precompute perf a bit 306 | 307 | ### Features 308 | 309 | - Add a command 310 | - Open command 311 | - Make open loop infinetely unless explicitly aborted 312 | - Explore cmd 313 | - Color in fuzzy search 314 | - Surf command 315 | - Help and config 316 | - Unlink command 317 | - Add remove command 318 | - Rename command 319 | - Add code blocks parsing to surf 320 | - Print, select commands and watch_mdtree script 321 | - Make navigable by and as well 322 | - Structural preview of notes 323 | - Parse out task-items, display as subtrees 324 | - Toggle state of multiple selection 325 | 326 | ### Miscellaneous Tasks 327 | 328 | - Rename package 329 | - Add gif tutorial 330 | - Make Structure the default preview type 331 | - Replace pic of logo 332 | - Make default skim proportion 35/65 (fuzzy list/preview) 333 | - Pics 334 | - Normal names for commands and short aliases redefined 335 | - . 336 | 337 | 338 | -------------------------------------------------------------------------------- /src/config/macros.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! impl_try_from_kdl_node { 3 | ($type: ident, $parent: expr, $($field: ident),+ ) => ( 4 | 5 | impl TryFrom<&KdlNode> for $type { 6 | type Error = miette::Report; 7 | 8 | fn try_from(value: &KdlNode) -> Result { 9 | let result = { 10 | let tags = [$(stringify!($field)),+]; 11 | let mut hashmap: HashMap<&'static str, &'_ KdlNode> = HashMap::new(); 12 | for tag in tags { 13 | 14 | let node = value 15 | .children() 16 | .ok_or(KdlNodeErrorType { 17 | err_span: value.span().clone(), 18 | description: format!("`{}` should have children", $parent), 19 | }) 20 | .map_err(|err| Into::::into(err))? 21 | .get(tag) 22 | .ok_or(KdlNodeErrorType { 23 | err_span: value.span().clone(), 24 | description: format!("no `{}.{}` in config", $parent, tag), 25 | }) 26 | .map_err(|err| Into::::into(err))?; 27 | hashmap.insert(tag, node); 28 | } 29 | $type { 30 | $($field: hashmap[stringify!($field)].try_into()?),+ 31 | 32 | } 33 | }; 34 | 35 | Ok(result) 36 | 37 | } 38 | } 39 | ) 40 | } 41 | #[macro_export] 42 | macro_rules! impl_try_from_kdl_node_tagged { 43 | ($type: ident, $parent: expr, $($tag: expr => $field: ident),+ ) => ( 44 | 45 | impl TryFrom<&KdlNode> for $type { 46 | type Error = miette::Report; 47 | 48 | fn try_from(value: &KdlNode) -> Result { 49 | let result = { 50 | let tags = [$($tag),+]; 51 | let mut hashmap: HashMap<&'static str, &'_ KdlNode> = HashMap::new(); 52 | for tag in tags { 53 | 54 | let node = value 55 | .children() 56 | .ok_or(KdlNodeErrorType { 57 | err_span: value.span().clone(), 58 | description: format!("`{}` should have children", $parent), 59 | }) 60 | .map_err(|err| Into::::into(err))? 61 | .get(tag) 62 | .ok_or(KdlNodeErrorType { 63 | err_span: value.span().clone(), 64 | description: format!("no `{}.{}` in config", $parent, tag), 65 | }) 66 | .map_err(|err| Into::::into(err))?; 67 | hashmap.insert(tag, node); 68 | } 69 | $type { 70 | $($field: hashmap[$tag].try_into()? 71 | ),+ 72 | 73 | } 74 | }; 75 | 76 | Ok(result) 77 | 78 | } 79 | } 80 | ) 81 | } 82 | 83 | pub mod keymap { 84 | 85 | #[macro_export] 86 | macro_rules! impl_try_from_kdl_node_uniqueness_check { 87 | ($type: ident, $parent: expr, $($field: ident),+ ) => ( 88 | 89 | impl TryFrom<&KdlNode> for $type { 90 | type Error = miette::Report; 91 | 92 | fn try_from(value: &KdlNode) -> Result { 93 | let result = { 94 | let tags = [$(stringify!($field)),+]; 95 | let mut hashmap: HashMap<&'static str, &'_ KdlNode> = HashMap::new(); 96 | for tag in tags { 97 | 98 | let node = value 99 | .children() 100 | .ok_or(KdlNodeErrorType { 101 | err_span: value.span().clone(), 102 | description: format!("`{}` should have children", $parent), 103 | }) 104 | .map_err(|err| Into::::into(err))? 105 | .get(tag) 106 | .ok_or(KdlNodeErrorType { 107 | err_span: value.span().clone(), 108 | description: format!("no `{}.{}` in config", $parent, tag), 109 | }) 110 | .map_err(|err| Into::::into(err))?; 111 | hashmap.insert(tag, node); 112 | } 113 | $type { 114 | $($field: hashmap[stringify!($field)].try_into()?),+ 115 | 116 | } 117 | }; 118 | let mut count = 0; 119 | let mut keys_hash_set = HashSet::new(); 120 | { 121 | 122 | $( 123 | count += 1; 124 | keys_hash_set.insert(result.$field.clone()); 125 | 126 | )+ 127 | } 128 | if count != keys_hash_set.len() { 129 | let err = KdlNodeErrorType { 130 | err_span: value.span().clone(), 131 | description: "has keys combo repetitions".to_string(), 132 | }; 133 | return Err(Into::::into(err)); 134 | } 135 | 136 | Ok(result) 137 | 138 | } 139 | } 140 | ) 141 | } 142 | 143 | #[macro_export] 144 | macro_rules! impl_from_self_into_action_hashmap { 145 | ($type: ident, $action_type: ident, $($variant: expr => $field: ident | $skim_action: expr),+ ) => ( 146 | 147 | #[derive(Debug, Clone)] 148 | pub struct Bindings(HashMap<(SingleKey, String), $action_type>); 149 | 150 | 151 | impl From<$type> for Bindings { 152 | fn from(value: $type) -> Self { 153 | let mut result = HashMap::new(); 154 | $(result.insert((value.$field, $skim_action), $variant));+ 155 | ; 156 | 157 | Bindings(result) 158 | } 159 | } 160 | impl Bindings { 161 | pub fn keys_descriptors(&self) -> Vec { 162 | 163 | self 164 | .0 165 | .keys() 166 | .map(|(key, skim_action)| format!("{}:{}", key.combo, skim_action)) 167 | .collect::>() 168 | 169 | } 170 | } 171 | 172 | impl From<&Bindings> for HashMap { 173 | fn from(value: &Bindings) -> Self { 174 | value.0.iter().map(|(k,v)| { 175 | (k.0.tui_combo.clone(), v.clone()) 176 | }).collect::>() 177 | 178 | } 179 | 180 | } 181 | ) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/skim/stack_sequential.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, rc::Rc, sync::Arc}; 2 | 3 | use skim::{ 4 | prelude::{unbounded, DefaultSkimSelector, Key, SkimOptionsBuilder}, 5 | Selector, Skim, SkimItemReceiver, SkimItemSender, 6 | }; 7 | 8 | use crate::{ 9 | config::{color::ColorScheme, keymap, ExternalCommands, SurfParsing}, 10 | database::SqliteAsyncHandle, 11 | highlight::MarkdownStatic, 12 | note::{DynResources, Note, PreviewType}, 13 | }; 14 | pub enum Action { 15 | Select(Vec), 16 | Return(Vec), 17 | TogglePreview, 18 | Pop(Note), 19 | MoveTopmost(Note), 20 | SwapWithAbove(Note), 21 | SwapWithBelow(Note), 22 | } 23 | 24 | pub(crate) struct Iteration { 25 | db: SqliteAsyncHandle, 26 | input_items_from_explore: Vec, 27 | items: Option>, 28 | multi: bool, 29 | preview_type: PreviewType, 30 | hint: String, 31 | external_commands: ExternalCommands, 32 | surf_parsing: SurfParsing, 33 | md_static: MarkdownStatic, 34 | 35 | color_scheme: ColorScheme, 36 | straight: bool, 37 | nested_threshold: usize, 38 | bindings_map: keymap::stack::Bindings, 39 | preselected_item: Option, 40 | } 41 | 42 | impl Iteration { 43 | #[allow(clippy::too_many_arguments)] 44 | pub(crate) fn new( 45 | hint: String, 46 | input_items_from_explore: Vec, 47 | items: Vec, 48 | db: SqliteAsyncHandle, 49 | multi: bool, 50 | preview_type: PreviewType, 51 | external_commands: ExternalCommands, 52 | surf_parsing: SurfParsing, 53 | md_static: MarkdownStatic, 54 | color_scheme: ColorScheme, 55 | straight: bool, 56 | nested_threshold: usize, 57 | bindings_map: keymap::stack::Bindings, 58 | selected_item: Option, 59 | ) -> Self { 60 | Self { 61 | items: Some(items), 62 | db, 63 | input_items_from_explore, 64 | multi, 65 | preview_type, 66 | external_commands, 67 | surf_parsing, 68 | hint, 69 | md_static, 70 | color_scheme, 71 | straight, 72 | nested_threshold, 73 | bindings_map, 74 | preselected_item: selected_item, 75 | } 76 | } 77 | 78 | pub(crate) async fn run(mut self) -> anyhow::Result { 79 | let items = self.items.take().unwrap(); 80 | 81 | let (tx, rx): (SkimItemSender, SkimItemReceiver) = unbounded(); 82 | 83 | let db = self.db; 84 | let db_double = db.clone(); 85 | let ext_double = self.external_commands.clone(); 86 | let surf_parsing = self.surf_parsing.clone(); 87 | 88 | tokio::task::spawn(async move { 89 | for mut note in items { 90 | let ext_double = ext_double.clone(); 91 | let surf_parsing = surf_parsing.clone(); 92 | note.set_resources(DynResources { 93 | external_commands: ext_double, 94 | surf_parsing, 95 | preview_type: self.preview_type, 96 | preview_result: None, 97 | }); 98 | note.prepare_preview( 99 | &db_double, 100 | self.md_static, 101 | self.color_scheme, 102 | self.straight, 103 | self.nested_threshold, 104 | ) 105 | .await; 106 | let result = tx.send(Arc::new(note)); 107 | if result.is_err() { 108 | // eprintln!("{}",format!("{:?}", result).red()); 109 | } 110 | } 111 | }); 112 | 113 | let hint = self.hint; 114 | let keys_descriptors = self.bindings_map.keys_descriptors(); 115 | let out = tokio::task::spawn_blocking(move || { 116 | let mut bindings = vec!["ctrl-c:abort", "ESC:abort", "Enter:accept"]; 117 | bindings.extend( 118 | keys_descriptors 119 | .into_iter() 120 | .map(|element| &*(Box::::leak(element.into_boxed_str()))), 121 | ); 122 | let hint = format!("({hint}) > "); 123 | let selector = self.preselected_item.map(|item| { 124 | let preset_items = vec![item]; 125 | let selector = DefaultSkimSelector::default().preset(preset_items); 126 | let selector: Rc = Rc::new(selector); 127 | selector 128 | }); 129 | let options = SkimOptionsBuilder::default() 130 | .height(Some("100%")) 131 | .preview(Some("")) 132 | .prompt(Some(&hint)) 133 | .preview_window(Some("up:60%")) 134 | .multi(self.multi) 135 | .bind(bindings) 136 | .selector(selector) 137 | .build() 138 | .unwrap(); 139 | 140 | Skim::run_with(&options, Some(rx)) 141 | }) 142 | .await 143 | .unwrap(); 144 | 145 | let bindings_map: HashMap = 146 | (&self.bindings_map).into(); 147 | if let Some(out) = out { 148 | let selected_items = out 149 | .selected_items 150 | .iter() 151 | .map(|selected_item| { 152 | (**selected_item) 153 | .as_any() 154 | .downcast_ref::() 155 | .unwrap() 156 | .to_owned() 157 | }) 158 | .collect::>(); 159 | 160 | let action = match out.final_key { 161 | Key::Ctrl('c') | Key::ESC => { 162 | return Err(anyhow::anyhow!( 163 | "user chose to abort current iteration of open cycle" 164 | )) 165 | } 166 | Key::Enter => { 167 | return Ok(Action::Select(selected_items)); 168 | } 169 | key @ Key::Ctrl(..) | key @ Key::Alt(..) => bindings_map.get(&key).cloned(), 170 | _ => { 171 | unreachable!(); 172 | } 173 | }; 174 | let Some(action) = action else { 175 | unreachable!("an unspecified keybinding isn't expected to pick None from Hashmap"); 176 | }; 177 | match action { 178 | keymap::stack::Action::ReturnToExplore => { 179 | if let Some(_item) = selected_items.first() { 180 | Ok(Action::Return(self.input_items_from_explore)) 181 | } else { 182 | Err(anyhow::anyhow!("no item selected")) 183 | } 184 | } 185 | keymap::stack::Action::TogglePreviewType => { 186 | if let Some(_item) = selected_items.first() { 187 | Ok(Action::TogglePreview) 188 | } else { 189 | Err(anyhow::anyhow!("no item selected")) 190 | } 191 | } 192 | keymap::stack::Action::PopNoteFromStack => { 193 | if let Some(item) = selected_items.first() { 194 | Ok(Action::Pop(item.clone())) 195 | } else { 196 | Err(anyhow::anyhow!("no item selected")) 197 | } 198 | } 199 | keymap::stack::Action::MoveNoteToStackTop => { 200 | if let Some(item) = selected_items.first() { 201 | Ok(Action::MoveTopmost(item.clone())) 202 | } else { 203 | Err(anyhow::anyhow!("no item selected")) 204 | } 205 | } 206 | keymap::stack::Action::SwapWithAbove => { 207 | if let Some(item) = selected_items.first() { 208 | Ok(Action::SwapWithAbove(item.clone())) 209 | } else { 210 | Err(anyhow::anyhow!("no item selected")) 211 | } 212 | } 213 | keymap::stack::Action::SwapWithBelow => { 214 | if let Some(item) = selected_items.first() { 215 | Ok(Action::SwapWithBelow(item.clone())) 216 | } else { 217 | Err(anyhow::anyhow!("no item selected")) 218 | } 219 | } 220 | keymap::stack::Action::DeselectAll => { 221 | unreachable!("deselect_all must be unreachable"); 222 | } 223 | } 224 | } else { 225 | Err(anyhow::anyhow!("skim internal errors")) 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/note/mod.rs: -------------------------------------------------------------------------------- 1 | use colored::Colorize; 2 | use std::fmt::Display; 3 | use std::hash::{Hash, Hasher}; 4 | use std::io::Write; 5 | use std::{fs::File, io, path::PathBuf}; 6 | use syntect::easy::HighlightLines; 7 | 8 | use crate::config::color::ColorScheme; 9 | use crate::config::Open as OpenCfg; 10 | use crate::config::{ExternalCommands, SurfParsing}; 11 | use crate::database::SqliteAsyncHandle; 12 | use crate::highlight::{highlight, MarkdownStatic}; 13 | use crate::Open; 14 | mod links_term_tree; 15 | mod random; 16 | mod reachable; 17 | mod skim_item; 18 | mod task_items_term_tree; 19 | pub use self::task_items_term_tree::NoteTaskItemTerm; 20 | use crate::database::Database; 21 | use duct::cmd; 22 | use sqlx::Result as SqlxResult; 23 | 24 | #[derive(Clone, Debug, Copy, PartialEq, Eq, Hash)] 25 | pub enum PreviewType { 26 | Details, 27 | LinkStructure, 28 | TaskStructure, 29 | } 30 | 31 | impl PreviewType { 32 | pub fn toggle(&self) -> Self { 33 | match self { 34 | Self::Details => Self::LinkStructure, 35 | Self::LinkStructure => Self::TaskStructure, 36 | Self::TaskStructure => Self::Details, 37 | } 38 | } 39 | } 40 | 41 | impl Default for PreviewType { 42 | fn default() -> Self { 43 | Self::LinkStructure 44 | } 45 | } 46 | 47 | #[derive(Clone, Debug)] 48 | pub struct DynResources { 49 | pub external_commands: ExternalCommands, 50 | pub surf_parsing: SurfParsing, 51 | pub preview_type: PreviewType, 52 | pub preview_result: Option, 53 | } 54 | 55 | #[derive(Clone, Debug)] 56 | pub enum Note { 57 | MdFile { 58 | name: String, 59 | file_path: PathBuf, 60 | resources: Option, 61 | name_markdown: Option, 62 | color_scheme: ColorScheme, 63 | }, 64 | Tag { 65 | name: String, 66 | resources: Option, 67 | color_scheme: ColorScheme, 68 | }, 69 | } 70 | 71 | impl Hash for Note { 72 | fn hash(&self, state: &mut H) { 73 | self.name().hash(state); 74 | } 75 | } 76 | 77 | impl PartialEq for Note { 78 | fn eq(&self, other: &Self) -> bool { 79 | self.name() == other.name() 80 | } 81 | } 82 | 83 | impl Display for Note { 84 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 85 | match self { 86 | Self::MdFile { name_markdown, .. } => { 87 | let md = name_markdown.as_ref().cloned().unwrap(); 88 | write!(f, "{}", md) 89 | } 90 | 91 | Self::Tag { 92 | name, color_scheme, .. 93 | } => { 94 | if name == "METATAG" || name == "root" { 95 | let special_tag = color_scheme.notes.special_tag; 96 | write!( 97 | f, 98 | "{}", 99 | name.truecolor(special_tag.0.r, special_tag.0.g, special_tag.0.b) 100 | ) 101 | } else { 102 | let tag = color_scheme.notes.tag; 103 | write!(f, "{}", name.truecolor(tag.0.r, tag.0.g, tag.0.b)) 104 | } 105 | } 106 | } 107 | } 108 | } 109 | 110 | impl Open for Note { 111 | fn open(&self, mut cfg: OpenCfg) -> io::Result> { 112 | if let Some(file_path) = self.file_path() { 113 | let file_cmd = PathBuf::from(&cfg.file_cmd.command); 114 | let file_cmd = env_substitute::substitute(file_cmd); 115 | println!("{:?}", file_cmd); 116 | cfg.file_cmd 117 | .replace_matching_element("$FILE", file_path.to_str().unwrap_or("bad utf path")); 118 | Ok(Some( 119 | cmd(file_cmd.to_str().unwrap().to_owned(), cfg.file_cmd.args) 120 | .run()? 121 | .status, 122 | )) 123 | } else { 124 | Ok(None) 125 | } 126 | } 127 | 128 | fn open_xdg(&self) -> Result<(), opener::OpenError> { 129 | if let Some(file_path) = self.file_path() { 130 | opener::open(file_path) 131 | } else { 132 | Ok(()) 133 | } 134 | } 135 | } 136 | 137 | impl Eq for Note {} 138 | 139 | impl Note { 140 | pub(crate) fn new(name: String, file_path: Option, color_scheme: ColorScheme) -> Self { 141 | match file_path { 142 | Some(file_path) => Self::MdFile { 143 | name, 144 | file_path, 145 | resources: None, 146 | name_markdown: None, 147 | color_scheme, 148 | }, 149 | None => Self::Tag { 150 | name, 151 | resources: None, 152 | color_scheme, 153 | }, 154 | } 155 | } 156 | pub(crate) fn rename( 157 | &mut self, 158 | new_name: &str, 159 | highlighter: &mut HighlightLines, 160 | md_static: MarkdownStatic, 161 | ) { 162 | match self { 163 | Self::MdFile { ref mut name, .. } => { 164 | *name = new_name.to_string(); 165 | } 166 | Self::Tag { ref mut name, .. } => { 167 | *name = new_name.to_string(); 168 | } 169 | } 170 | self.set_markdown(highlighter, md_static); 171 | } 172 | 173 | pub(crate) fn set_markdown( 174 | &mut self, 175 | highlighter: &mut HighlightLines, 176 | md_static: MarkdownStatic, 177 | ) { 178 | match self { 179 | Self::MdFile { 180 | name, 181 | ref mut name_markdown, 182 | .. 183 | } => { 184 | let markdown = format!( 185 | "{} {}", 186 | highlight(name, highlighter, md_static), 187 | " ".truecolor(0, 0, 0) 188 | ); 189 | *name_markdown = Some(markdown); 190 | } 191 | Self::Tag { .. } => { 192 | // nothing 193 | } 194 | } 195 | } 196 | 197 | pub(crate) fn init( 198 | name: String, 199 | is_tag: bool, 200 | highlighter: &mut HighlightLines, 201 | md_static: MarkdownStatic, 202 | 203 | color_scheme: ColorScheme, 204 | ) -> Self { 205 | let time_str = chrono::Utc::now().naive_utc().timestamp().to_string(); 206 | let suffix = random::rand_suffix(); 207 | let fname = format!("{}_{}.md", time_str, suffix); 208 | 209 | let file_path = (!is_tag).then_some(PathBuf::from("./").join(fname)); 210 | let mut note = Self::new(name, file_path, color_scheme); 211 | note.set_markdown(highlighter, md_static); 212 | note 213 | } 214 | 215 | pub(crate) fn persist(&self) -> Result<(), io::Error> { 216 | if let Self::MdFile { file_path, .. } = &self { 217 | let mut output = File::create(file_path.as_path())?; 218 | writeln!(output, "# 💖 {}", self.name())?; 219 | } 220 | Ok(()) 221 | } 222 | pub fn name(&self) -> String { 223 | match &self { 224 | Self::MdFile { name, .. } => name.clone(), 225 | Self::Tag { name, .. } => name.clone(), 226 | } 227 | } 228 | 229 | pub fn file_path(&self) -> Option<&PathBuf> { 230 | match self { 231 | Self::MdFile { file_path, .. } => Some(file_path), 232 | Self::Tag { .. } => None, 233 | } 234 | } 235 | pub fn set_resources(&mut self, to_set: DynResources) { 236 | match self { 237 | Self::MdFile { resources, .. } => { 238 | *resources = Some(to_set); 239 | } 240 | Self::Tag { resources, .. } => { 241 | *resources = Some(to_set); 242 | } 243 | } 244 | } 245 | 246 | fn resources_mut(&mut self) -> Option<&mut DynResources> { 247 | match self { 248 | Self::MdFile { 249 | ref mut resources, .. 250 | } => resources.as_mut(), 251 | Self::Tag { 252 | ref mut resources, .. 253 | } => resources.as_mut(), 254 | } 255 | } 256 | fn resources(&self) -> Option<&DynResources> { 257 | match self { 258 | Self::MdFile { ref resources, .. } => resources.as_ref(), 259 | Self::Tag { ref resources, .. } => resources.as_ref(), 260 | } 261 | } 262 | 263 | pub async fn fetch_forward_links( 264 | &self, 265 | db: &SqliteAsyncHandle, 266 | md_static: MarkdownStatic, 267 | color_scheme: ColorScheme, 268 | straight: bool, 269 | ) -> SqlxResult> { 270 | db.lock() 271 | .await 272 | .find_links_from(&self.name(), md_static, color_scheme, straight) 273 | .await 274 | } 275 | 276 | pub async fn fetch_backlinks( 277 | &self, 278 | db: &SqliteAsyncHandle, 279 | md_static: MarkdownStatic, 280 | color_scheme: ColorScheme, 281 | straight: bool, 282 | ) -> SqlxResult> { 283 | db.lock() 284 | .await 285 | .find_links_to(&self.name(), md_static, color_scheme, straight) 286 | .await 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /src/note/task_items_term_tree.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashSet, fmt::Display, fs, path::PathBuf}; 2 | 3 | use crate::{ 4 | config::{color::ColorScheme, SurfParsing}, 5 | database::{Database, SqliteAsyncHandle}, 6 | highlight::MarkdownStatic, 7 | lines::find_position, 8 | task_item::TaskItem, 9 | Jump, 10 | }; 11 | use async_recursion::async_recursion; 12 | use bidir_termtree::{Down, Tree}; 13 | use colored::Colorize; 14 | use syntect::easy::HighlightLines; 15 | 16 | use super::Note; 17 | use duct::cmd; 18 | use sqlx::Result as SqlxResult; 19 | 20 | #[allow(clippy::large_enum_variant)] 21 | #[derive(Clone)] 22 | pub enum NoteTaskItemTerm { 23 | Note(Note), 24 | Task(TaskItem), 25 | TaskMono(TaskItem), 26 | TaskHint(bool, usize, ColorScheme), 27 | Cycle(String, ColorScheme), 28 | } 29 | 30 | impl Display for NoteTaskItemTerm { 31 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 32 | match self { 33 | Self::Note(note) => { 34 | write!(f, "{}", note) 35 | } 36 | 37 | Self::Task(task) => { 38 | write!(f, "{}", task.skim_display(false)) 39 | } 40 | Self::TaskHint(only_hint, num, color) => { 41 | if *only_hint { 42 | let c = color.links.unlisted; 43 | 44 | write!( 45 | f, 46 | "{}", 47 | format!("{num} task items unlisted").truecolor(c.0.r, c.0.g, c.0.b) 48 | ) 49 | } else { 50 | let c = color.links.unlisted; 51 | 52 | write!( 53 | f, 54 | "{}", 55 | format!("{num} task items ").truecolor(c.0.r, c.0.g, c.0.b) 56 | ) 57 | } 58 | } 59 | Self::TaskMono(task) => { 60 | write!(f, "{}", task.skim_display_mono(false)) 61 | } 62 | Self::Cycle(cycle, color) => { 63 | let c = color.links.cycle; 64 | write!(f, "⟳ {}", cycle.truecolor(c.0.r, c.0.g, c.0.b)) 65 | } 66 | } 67 | } 68 | } 69 | 70 | impl NoteTaskItemTerm { 71 | pub fn len_task_items(&self) -> usize { 72 | match self { 73 | Self::Task(item) | Self::TaskMono(item) => { 74 | let start = item.self_index; 75 | let next = match item.next_index { 76 | Some(next) => next, 77 | None => start + 1, 78 | }; 79 | next - start 80 | } 81 | _ => 0, 82 | } 83 | } 84 | pub fn parse( 85 | input: &[TaskItem], 86 | group_by_top_level: bool, 87 | mono: bool, 88 | ) -> Vec> { 89 | let mut result = vec![]; 90 | let mut subrange_end = 0; 91 | let mut index = 0; 92 | while index < input.len() { 93 | if group_by_top_level && index < subrange_end { 94 | index = subrange_end; 95 | if index >= input.len() { 96 | break; 97 | } 98 | } 99 | let mut tree; 100 | if mono { 101 | tree = Tree::new(NoteTaskItemTerm::TaskMono(input[index].clone())); 102 | } else { 103 | tree = Tree::new(NoteTaskItemTerm::Task(input[index].clone())); 104 | } 105 | let current_offset = input[index].nested_level; 106 | let subrange_start = index + 1; 107 | subrange_end = index + 1; 108 | if subrange_start < input.len() { 109 | while subrange_end < input.len() 110 | && input[subrange_end].nested_level > current_offset 111 | { 112 | subrange_end += 1; 113 | } 114 | let subslice = &input[subrange_start..subrange_end]; 115 | let height_task = subrange_end - index; 116 | match tree.root { 117 | NoteTaskItemTerm::Note(..) => unreachable!("note"), 118 | NoteTaskItemTerm::Cycle(..) => unreachable!("cycle"), 119 | NoteTaskItemTerm::TaskHint(_num, ..) => unreachable!("hint"), 120 | NoteTaskItemTerm::Task(ref mut task) 121 | | NoteTaskItemTerm::TaskMono(ref mut task) => { 122 | task.next_index = Some(task.self_index + height_task); 123 | } 124 | } 125 | let children = NoteTaskItemTerm::parse(subslice, true, mono); 126 | for child in children { 127 | tree.push(child); 128 | } 129 | } 130 | result.push(tree); 131 | index += 1; 132 | } 133 | result 134 | } 135 | } 136 | 137 | impl Jump for NoteTaskItemTerm { 138 | fn jump( 139 | &self, 140 | mut cfg: crate::config::Open, 141 | ) -> std::io::Result> { 142 | let task = match self { 143 | NoteTaskItemTerm::Note(..) => unreachable!("not expecting a note here"), 144 | NoteTaskItemTerm::Cycle(..) => unreachable!("not expecting a cycle here"), 145 | NoteTaskItemTerm::TaskHint(_num, ..) => unreachable!("hint"), 146 | NoteTaskItemTerm::Task(task) => task.clone(), 147 | NoteTaskItemTerm::TaskMono(task) => task.clone(), 148 | }; 149 | 150 | let initial_contents: &str = &fs::read_to_string(&task.file_name)?; 151 | let offset = task.checkmark_offsets_in_string.start; 152 | let position = find_position(initial_contents, offset); 153 | 154 | let file_cmd = PathBuf::from(&cfg.file_jump_cmd.command); 155 | let file_cmd = env_substitute::substitute(file_cmd); 156 | 157 | cfg.file_jump_cmd.replace_in_matching_element( 158 | "$FILE", 159 | task.file_name.to_str().unwrap_or("bad utf path"), 160 | ); 161 | 162 | cfg.file_jump_cmd 163 | .replace_in_matching_element("$LINE", &format!("{}", position.line)); 164 | 165 | cfg.file_jump_cmd 166 | .replace_in_matching_element("$COLUMN", &format!("{}", position.column)); 167 | 168 | Ok(Some( 169 | cmd( 170 | file_cmd.to_str().unwrap().to_owned(), 171 | cfg.file_jump_cmd.args, 172 | ) 173 | .run()? 174 | .status, 175 | )) 176 | } 177 | } 178 | 179 | impl Note { 180 | #[allow(clippy::too_many_arguments)] 181 | #[async_recursion] 182 | pub async fn construct_task_item_term_tree( 183 | &self, 184 | level: usize, 185 | nested_threshold: usize, 186 | mut all_reachable: HashSet, 187 | surf_parsing: SurfParsing, 188 | db: SqliteAsyncHandle, 189 | md_static: MarkdownStatic, 190 | color_scheme: ColorScheme, 191 | straight: bool, 192 | ) -> SqlxResult<(Tree, HashSet)> { 193 | let mut tree = Tree::new(NoteTaskItemTerm::Note(self.clone())); 194 | all_reachable.insert(self.clone()); 195 | 196 | let tasks = { 197 | let mut highlighter = HighlightLines::new(md_static.1, md_static.2); 198 | TaskItem::parse(self, &surf_parsing, &mut highlighter, md_static)? 199 | }; 200 | 201 | let task_trees = NoteTaskItemTerm::parse(&tasks, true, false); 202 | if !task_trees.is_empty() { 203 | let sum_len = task_trees 204 | .iter() 205 | .fold(0, |acc, element| acc + element.root.len_task_items()); 206 | if level >= nested_threshold { 207 | tree.push(NoteTaskItemTerm::TaskHint(true, sum_len, color_scheme)); 208 | } else { 209 | let hint = NoteTaskItemTerm::TaskHint(false, sum_len, color_scheme); 210 | let mut hint_tree = Tree::new(hint); 211 | for task in task_trees { 212 | hint_tree.push(task); 213 | } 214 | tree.push(hint_tree); 215 | } 216 | } 217 | 218 | let forward_links = db 219 | .lock() 220 | .await 221 | .find_links_from(&self.name(), md_static, color_scheme, straight) 222 | .await?; 223 | 224 | for next in forward_links.into_iter().rev() { 225 | if all_reachable.contains(&next) { 226 | tree.push(Tree::new(NoteTaskItemTerm::Cycle( 227 | next.name(), 228 | color_scheme, 229 | ))); 230 | } else { 231 | let (next_tree, roundtrip_reachable) = next 232 | .construct_task_item_term_tree( 233 | level + 1, 234 | nested_threshold, 235 | all_reachable, 236 | surf_parsing.clone(), 237 | db.clone(), 238 | md_static, 239 | color_scheme, 240 | straight, 241 | ) 242 | .await?; 243 | all_reachable = roundtrip_reachable; 244 | tree.push(next_tree); 245 | } 246 | } 247 | 248 | Ok((tree, all_reachable)) 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /KEYBINDINGS.md: -------------------------------------------------------------------------------- 1 | # Config 2 | 3 | When configuring keys only `Ctrl` and `Alt` combinations are allowed with single char. 4 | E.g., `"ctrl-a"` and `"alt-x"` are valid string values for keymap config, and `"ctrl-alt-t"` and `"ctrl-ty"` are not. 5 | 6 | # Keybindings of `explore` command 7 | 8 | - `explore` mode 9 | 10 | | Binding| Congirurable | Effect | 11 | |--------|--------------|-------------------------------------------------------------------------------------------------------------| 12 | | Ctrl-c | no | Abort | 13 | | ESC | no | Abort | 14 | | Enter | no | Open selected note in editor | 15 | | | | | 16 | | Ctrl-o | yes | Open selected note with `xdg-open` or `dio`, or its corresponding counterpart in the host operating system | 17 | | Ctrl-h | yes | Populate skim selection with backlinks of selected note | 18 | | Ctrl-l | yes | Populate skim selection with forward links of selected note | 19 | | Ctrl-t | yes | Toggle preview type of notes | 20 | | Ctrl-w | yes | Widen skim selection to full list of all notes | 21 | | Ctrl-s | yes | Switch mode to `surf` with the selected note as the root of surfed subtree | 22 | | Ctrl-k | yes | Switch mode to `checkmark` for task items of selected note | 23 | | Alt-r | yes | Rename selected note | 24 | | Alt-l | yes | Create a link from selected note to another, selected in next skim iteration | 25 | | Alt-u | yes | Remove a link from selected note to one of its forward links | 26 | | Alt-d | yes | Remove selected note | 27 | | Alt-c | yes | Create a new note/tag, which will become one of selected note's forward links | 28 | | Alt-f | yes | Toggle/invert the direction of links. Backlinks become forward links | 29 | | Alt-s | yes | Splice note: populate selection list with its children, reachable by forward links | 30 | | Alt-n | yes | Narrow selection to single or multiple selected notes | 31 | | Alt-o | yes | Decrease threshold of nested level for unlisted inner items (links, task items) | 32 | | Alt-p | yes | Increase threshold of nested level for unlisted inner items (links, task items) | 33 | | Alt-a | yes | Push selected note to `GLOBAL` stack | 34 | | Ctrl-a | yes | Switch mode to `stack` (viewing `GLOBAL` stack) | 35 | 36 | - `surf` mode 37 | 38 | | Binding | Congirurable | Effect | 39 | |----------|---------------|-----------------------------------------------------------------------------------------------------------| 40 | | Ctrl-c | no | Abort | 41 | | ESC | no | Abort | 42 | | Enter | no | Open selected `[markdown link]()` with a command, depending on [markdown link]()'s type | 43 | | | | | 44 | | Ctrl-o | yes | Open selected link with `xdg-open` or `dio`, or its corresponding counterpart in the host operating system| 45 | | Ctrl-j | yes | Jump to selected `[markdown link]()`'s position in editor | 46 | | Ctrl-e | yes | Return to `explore` mode (in `explore` command) or abort `surf` command | 47 | 48 | - `checkmark` mode 49 | 50 | | Binding | Congirurable | Effect | 51 | |------------------|--------------|-----------------------------------------------------------------------------------| 52 | | Ctrl-c | no | Abort | 53 | | ESC | no | Abort | 54 | | TAB (skim) | no | Select and move down | 55 | | Shift+TAB (skim) | no | Select and move up | 56 | | Enter | no | Toggle state todo/done of multiple selected task items | 57 | | | | | 58 | | Ctrl-j | yes | Jump to selected task item's position in editor | 59 | | Ctrl-y | yes | Copy selected task item's subtree to clipboard | 60 | | Ctrl-w | yes | Widen context of task items to all tasks, parse again from file | 61 | | Ctrl-l | yes | Narrown context of task items to subtree of selected task item | 62 | | Ctrl-e | yes | Return to `explore` mode (in `explore` command) or abort `checkmark` command | 63 | 64 | - `stack` mode 65 | 66 | | Binding | Configurable | Effect | 67 | |---------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------| 68 | | Ctrl-c | no | Abort | 69 | | ESC | no | Abort | 70 | | Enter | no | If called from withing `explore` command switch mode back to `explore` with selected note. From `stack` command print note's name and exit.| 71 | | | | | 72 | | Ctrl-t | yes | Toggle preview type of notes | 73 | | Alt-p | yes | Pop note from `GLOBAL` stack | 74 | | Alt-t | yes | Move note to top of `GLOBAL` stack | 75 | | Ctrl-e | yes | Return to `explore` mode without changes, as if `stack` mode wasn't switched to (if called from withing `explore` command) | 76 | | Alt-u | yes | Move note one up; it will stay selected on next select iteration; it must be deselected explicitly | 77 | | Alt-d | yes | Move note one down; it will stay selected on next select iteration; it must be deselected explicitly | 78 | | Ctrl-q | yes | Deselect all notes; may be used after selecting multiple notes with `TAB` or after moving one note up & down | 79 | 80 | - common more and less obvious keybindings from vanilla skim 81 | 82 | | Binding | Effect | 83 | |----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------| 84 | | Ctrl-p, Ctrl-k | Move up by one in skim selection | 85 | | Ctrl-n, Ctrl-j | Move down by one in skim selection, note that Ctrl-j is taken by jump action in `surf` and `checkmark` in default cfg, but that action can be bound to other keys | 86 | | PageUp | Move up by many items in skim selection | 87 | | PageDown |Move down by many items in skim selection | 88 | | Ctrl-r |Switch matching mode to regex and back to fuzzy? | 89 | | Shift-ArrowUp | Scroll preview port up (without mouse) | 90 | | Shirt-ArrowDown| Scroll preview port down (without mouse) | 91 | 92 | -------------------------------------------------------------------------------- /src/commands/explore.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use crate::{ 4 | commands::link::{link, link_noninteractive}, 5 | config::{color::ColorScheme, keymap, ExternalCommands, SurfParsing}, 6 | database::{Database, SqliteAsyncHandle}, 7 | highlight::MarkdownStatic, 8 | note::{Note, PreviewType}, 9 | print::format_two_tokens, 10 | skim::explore::{Action, Iteration}, 11 | Open, 12 | }; 13 | 14 | use super::{ 15 | checkmark::checkmark_note, create, remove::remove, rename::rename, stack::stack_select, 16 | surf::surf_note, unlink::unlink, 17 | }; 18 | use inquire::Select; 19 | use inquire::Text; 20 | use tokio::time::sleep; 21 | 22 | pub static GLOBAL_STACK: &str = "GLOBAL"; 23 | 24 | #[allow(clippy::too_many_arguments)] 25 | pub(crate) async fn exec( 26 | db: SqliteAsyncHandle, 27 | selected_note: Option>, 28 | external_commands: ExternalCommands, 29 | surf_parsing: SurfParsing, 30 | md_static: MarkdownStatic, 31 | color_scheme: ColorScheme, 32 | bindings_map: keymap::surf::Bindings, 33 | chck_bindings_map: keymap::checkmark::Bindings, 34 | stack_bindings_map: keymap::stack::Bindings, 35 | explore_bindings_map: keymap::explore::Bindings, 36 | ) -> Result { 37 | let mut list = match selected_note { 38 | Some(notes) => { 39 | let mut result = vec![]; 40 | for note in notes { 41 | let note = db.lock().await.get(¬e, md_static, color_scheme).await?; 42 | result.push(note); 43 | } 44 | result 45 | } 46 | None => db.lock().await.list(md_static, color_scheme).await?, 47 | }; 48 | let mut straight = true; 49 | 50 | let mut preview_type = PreviewType::default(); 51 | 52 | let mut nested_threshold = 1; 53 | loop { 54 | let (next_items, opened, preview_type_after) = iteration( 55 | db.clone(), 56 | list, 57 | &external_commands, 58 | &surf_parsing, 59 | preview_type, 60 | md_static, 61 | color_scheme, 62 | straight, 63 | nested_threshold, 64 | explore_bindings_map.clone(), 65 | ) 66 | .await?; 67 | preview_type = preview_type_after; 68 | list = next_items; 69 | 70 | match opened { 71 | Some(Action::Open(opened)) => { 72 | opened.open(external_commands.open.clone())?; 73 | eprintln!("{}", format_two_tokens("viewed", &opened.name())); 74 | } 75 | Some(Action::OpenXDG(opened)) => { 76 | opened.open_xdg()?; 77 | eprintln!("{}", format_two_tokens("viewed xdg", &opened.name())); 78 | } 79 | Some(Action::Surf(surfed)) => { 80 | if let Err(err) = surf_note( 81 | surfed, 82 | db.clone(), 83 | &external_commands, 84 | &surf_parsing, 85 | md_static, 86 | color_scheme, 87 | straight, 88 | bindings_map.clone(), 89 | ) 90 | .await 91 | { 92 | eprintln!("surf error: {:?}", err); 93 | } 94 | } 95 | 96 | Some(Action::Checkmark(surfed)) => { 97 | if let Err(err) = checkmark_note( 98 | surfed, 99 | &external_commands, 100 | &surf_parsing, 101 | md_static, 102 | chck_bindings_map.clone(), 103 | ) 104 | .await 105 | { 106 | eprintln!("checkmark error: {:?}", err); 107 | } 108 | } 109 | Some(Action::Rename(opened)) => { 110 | let note = rename(opened, db.clone(), md_static).await?; 111 | list = vec![note]; 112 | } 113 | 114 | Some(Action::Link(linked_from)) => { 115 | if let Err(err) = link( 116 | linked_from.clone(), 117 | db.clone(), 118 | &external_commands, 119 | &surf_parsing, 120 | md_static, 121 | color_scheme, 122 | straight, 123 | nested_threshold, 124 | ) 125 | .await 126 | { 127 | eprintln!("link error: {:?}", err); 128 | } 129 | // list = vec![linked_from]; 130 | } 131 | 132 | Some(Action::Unlink(unlinked_from)) => { 133 | if let Err(err) = unlink( 134 | unlinked_from.clone(), 135 | db.clone(), 136 | &external_commands, 137 | &surf_parsing, 138 | md_static, 139 | color_scheme, 140 | straight, 141 | ) 142 | .await 143 | { 144 | eprintln!("unlink error: {:?}", err); 145 | } 146 | // list = vec![unlinked_from]; 147 | } 148 | 149 | Some(Action::Remove(removed)) => { 150 | let mut success = true; 151 | for note in removed.clone() { 152 | match remove(db.clone(), note.clone(), true).await { 153 | Ok(true) => {} 154 | Ok(false) => { 155 | success = false; 156 | } 157 | Err(err) => { 158 | eprintln!("remove error: {:?}", err); 159 | success = false; 160 | } 161 | } 162 | } 163 | 164 | let next = match success { 165 | true => vec![], 166 | false => removed, 167 | }; 168 | list = next; 169 | } 170 | 171 | Some(Action::CreateLinkedFrom(linked_from)) => { 172 | let options: Vec<&str> = vec!["tag", "note"]; 173 | let note_type = Select::new("select note type", options).prompt()?; 174 | let is_tag = note_type == "tag"; 175 | 176 | let new_name = Text::new("Enter name of a new note:").prompt()?; 177 | let to = 178 | create::create(&new_name, db.clone(), is_tag, md_static, color_scheme).await?; 179 | 180 | link_noninteractive(linked_from.clone(), to, db.clone(), straight).await?; 181 | // list = vec![linked_from]; 182 | } 183 | 184 | Some(Action::InvertLinks) => { 185 | straight = !straight; 186 | } 187 | 188 | Some(Action::IncreaseUnlistedThreshold) => { 189 | nested_threshold += 1; 190 | } 191 | 192 | Some(Action::DecreaseUnlistedThreshold) => { 193 | nested_threshold = nested_threshold.saturating_sub(1); 194 | } 195 | 196 | Some(Action::PushToStack(note)) => { 197 | let name = ¬e.name(); 198 | if let Err(err) = db 199 | .lock() 200 | .await 201 | .push_note_to_stack(GLOBAL_STACK, ¬e.name()) 202 | .await 203 | { 204 | eprintln!("push to stack error: {:?}", err); 205 | } else { 206 | println!( 207 | "{}", 208 | format_two_tokens("pushed ", &format!("{name} to {GLOBAL_STACK}")) 209 | ); 210 | } 211 | sleep(Duration::new(1, 0)).await; 212 | } 213 | Some(Action::SwitchToStack) => { 214 | let next = stack_select( 215 | db.clone(), 216 | list.clone(), 217 | external_commands.clone(), 218 | surf_parsing.clone(), 219 | md_static, 220 | color_scheme, 221 | stack_bindings_map.clone(), 222 | ) 223 | .await?; 224 | list = next; 225 | } 226 | _ => {} 227 | } 228 | } 229 | } 230 | 231 | #[allow(clippy::too_many_arguments)] 232 | pub async fn iteration( 233 | db: SqliteAsyncHandle, 234 | list: Vec, 235 | external_commands: &ExternalCommands, 236 | surf_parsing: &SurfParsing, 237 | preview_type: PreviewType, 238 | md_static: MarkdownStatic, 239 | color_scheme: ColorScheme, 240 | straight: bool, 241 | nested_threshold: usize, 242 | bindings_map: keymap::explore::Bindings, 243 | ) -> Result<(Vec, Option, PreviewType), anyhow::Error> { 244 | let out = Iteration::new( 245 | list.clone(), 246 | db.clone(), 247 | external_commands.clone(), 248 | surf_parsing.clone(), 249 | preview_type, 250 | md_static, 251 | color_scheme, 252 | straight, 253 | nested_threshold, 254 | bindings_map, 255 | ) 256 | .run() 257 | .await?; 258 | 259 | let res = match out.action { 260 | Action::Back | Action::Forward => (out.next_items, None, preview_type), 261 | Action::Widen => ( 262 | db.lock().await.list(md_static, color_scheme).await?, 263 | None, 264 | preview_type, 265 | ), 266 | action @ Action::Open(..) => (out.next_items, Some(action), preview_type), 267 | action @ Action::OpenXDG(..) => (out.next_items, Some(action), preview_type), 268 | action @ Action::Rename(..) => (out.next_items, Some(action), preview_type), 269 | action @ Action::Link(..) => (out.next_items, Some(action), preview_type), 270 | action @ Action::Unlink(..) => (out.next_items, Some(action), preview_type), 271 | action @ Action::Remove(..) => (out.next_items, Some(action), preview_type), 272 | action @ Action::CreateLinkedFrom(..) => (out.next_items, Some(action), preview_type), 273 | action @ Action::Surf(..) => (out.next_items, Some(action), preview_type), 274 | action @ Action::Checkmark(..) => (out.next_items, Some(action), preview_type), 275 | action @ Action::InvertLinks => (out.next_items, Some(action), preview_type), 276 | action @ Action::Splice => (out.next_items, Some(action), preview_type), 277 | action @ Action::Narrow => (out.next_items, Some(action), preview_type), 278 | action @ Action::IncreaseUnlistedThreshold => (out.next_items, Some(action), preview_type), 279 | action @ Action::DecreaseUnlistedThreshold => (out.next_items, Some(action), preview_type), 280 | action @ Action::PushToStack(..) => (out.next_items, Some(action), preview_type), 281 | action @ Action::SwitchToStack => (out.next_items, Some(action), preview_type), 282 | Action::TogglePreview => (out.next_items, None, preview_type.toggle()), 283 | }; 284 | Ok(res) 285 | } 286 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate sql_builder; 3 | 4 | use clap::{Arg, ArgAction, ArgMatches}; 5 | 6 | use colored::Colorize; 7 | use config::{color::Color, Open as OpenCfg}; 8 | use highlight::static_markdown_syntax; 9 | use std::{ 10 | env, io, 11 | path::PathBuf, 12 | process::{exit, ExitStatus}, 13 | }; 14 | use syntect::highlighting::{Theme, ThemeSet}; 15 | 16 | mod commands; 17 | mod config; 18 | mod database; 19 | mod external_commands; 20 | mod highlight; 21 | mod lines; 22 | mod link; 23 | mod note; 24 | mod print; 25 | mod skim; 26 | mod task_item; 27 | 28 | pub(crate) use database::Sqlite; 29 | 30 | trait Open { 31 | fn open(&self, cfg: OpenCfg) -> io::Result>; 32 | 33 | fn open_xdg(&self) -> Result<(), opener::OpenError>; 34 | } 35 | 36 | trait Jump { 37 | fn jump(&self, cfg: OpenCfg) -> io::Result>; 38 | } 39 | 40 | trait Yank { 41 | fn yank(&self, cfg: OpenCfg) -> io::Result>; 42 | } 43 | 44 | fn load_theme(color: Color) -> Option<&'static Theme> { 45 | let theme = ThemeSet::get_theme(color.theme.0).ok(); 46 | match theme { 47 | Some(theme) => { 48 | let boxed_theme = Box::new(theme); 49 | let static_theme: &'static Theme = Box::leak(boxed_theme); 50 | Some(static_theme) 51 | } 52 | None => None, 53 | } 54 | } 55 | 56 | #[tokio::main(flavor = "multi_thread", worker_threads = 10)] 57 | async fn main() { 58 | env_logger::init(); 59 | let cmd = clap::Command::new("mds") 60 | .version("v0.19.2") 61 | .about("meudeus v0.19.2\na skim shredder for plain-text papers") 62 | .bin_name("mds") 63 | .arg(clap::arg!(-c --color "whether color output should be forced")) 64 | .subcommand_required(true) 65 | .subcommand(clap::command!("debug-cfg").about("print Debug representtion of config")) 66 | .subcommand( 67 | clap::command!("init") 68 | .about("`initialize` .sqlite database in notes dir, specified by config"), 69 | ) 70 | .subcommand( 71 | clap::command!("note") 72 | .visible_alias("n") 73 | .about("create a note") 74 | .arg( 75 | clap::arg!([title] "note title (unique name among notes and tags)") 76 | .value_parser(clap::value_parser!(String)) 77 | .required(true), 78 | ), 79 | ) 80 | .subcommand( 81 | clap::command!("tag") 82 | .visible_alias("t") 83 | .about("create a tag (note without file body)") 84 | .arg( 85 | clap::arg!([title] "tag title (unique name among notes and tags)") 86 | .value_parser(clap::value_parser!(String)) 87 | .required(true), 88 | ), 89 | ) 90 | .subcommand(clap::command!("select").about("select note S, i.e. print its name to stdout")) 91 | .subcommand( 92 | clap::command!("link") 93 | .visible_alias("l") 94 | .about("link 2 notes A -> B, selected twice in skim interface"), 95 | ) 96 | .subcommand( 97 | clap::command!("unlink") 98 | .visible_alias("ul") 99 | .about("unlink 2 notes A -> B, selected twice in skim interface"), 100 | ) 101 | .subcommand( 102 | clap::command!("remove") 103 | .visible_alias("rm") 104 | .about("remove note R, selected in skim interface"), 105 | ) 106 | .subcommand( 107 | clap::command!("rename") 108 | .visible_alias("mv") 109 | .about("rename note R, selected in skim interface"), 110 | ) 111 | .subcommand( 112 | clap::command!("print") 113 | .visible_alias("p") 114 | .about("print subgraph of notes and links reachable downwards from selected note P") 115 | .arg( 116 | clap::arg!(-n --name "note name") 117 | .value_parser(clap::value_parser!(String)) 118 | .required(false), 119 | ), 120 | ) 121 | .subcommand( 122 | clap::command!("explore") 123 | .arg( 124 | Arg::new("select") 125 | .short('s') 126 | .long("select") 127 | .action(ArgAction::Append) 128 | .value_name("NOTE_NAME_FULL") 129 | .help( 130 | "full name of note to start explore with (may be used multiple times)", 131 | ), 132 | ) 133 | .visible_alias("ex") 134 | .about("explore notes by (backlinks) , (links forward)"), 135 | ) 136 | .subcommand( 137 | clap::command!("surf").visible_alias("s").about( 138 | "surf through all links and code snippets found downwards from selected note S", 139 | ), 140 | ) 141 | .subcommand( 142 | clap::command!("stack") 143 | .visible_alias("st") 144 | .about("browse GLOBAL stack of notes"), 145 | ) 146 | .subcommand(clap::command!("checkmark").visible_alias("k").about( 147 | "checkmark, toggle state TODO/DONE of multiple task items, found in a selected note C", 148 | )); 149 | 150 | let matches = cmd.get_matches(); 151 | if matches.get_flag("color") { 152 | colored::control::set_override(true); 153 | } 154 | 155 | let result = body(&matches).await; 156 | match result { 157 | Ok(print) => println!("{}", print), 158 | Err(err) => { 159 | eprintln!("{}", format!("{:?}", err).truecolor(255, 0, 0)); 160 | exit(121) 161 | } 162 | } 163 | } 164 | 165 | async fn body(matches: &ArgMatches) -> anyhow::Result { 166 | let config = match config::Config::parse() { 167 | Ok(config) => config, 168 | Err(err) => { 169 | println!("{:?}", err); 170 | return Err(anyhow::anyhow!("config error")); 171 | } 172 | }; 173 | let loaded_theme = load_theme(config.color.clone()); 174 | 175 | if let Err(err) = env::set_current_dir(&config.work_dir.0) { 176 | eprintln!( 177 | "{}", 178 | format!("couldn't change work dir to {:?}", &config.work_dir).red() 179 | ); 180 | return Err(err)?; 181 | } 182 | 183 | let db_dir = PathBuf::from("./.sqlite"); 184 | let md_static = static_markdown_syntax(loaded_theme); 185 | let surf_bindings = config.keymap.surf.clone().try_into()?; 186 | let checkmark_bindings = config.keymap.checkmark.clone().try_into()?; 187 | let stack_bindings = config.keymap.stack.clone().try_into()?; 188 | let explore_bindings = config.keymap.explore.clone().try_into()?; 189 | 190 | let result = match matches.subcommand() { 191 | Some(("init", _matches)) => commands::init_db::exec(db_dir).await, 192 | Some(("debug-cfg", _matches)) => commands::debug_cfg::exec(config), 193 | Some((subcommand, matches)) => { 194 | let db = match Sqlite::new(false, db_dir).await { 195 | Ok(db) => db, 196 | Err(err) => return Err(err.into()), 197 | }; 198 | match subcommand { 199 | cmd @ "note" | cmd @ "tag" => { 200 | let is_tag = cmd == "tag"; 201 | let title = matches 202 | .get_one::("title") 203 | .ok_or(anyhow::anyhow!("empty title"))?; 204 | 205 | commands::create::exec(title, db, is_tag, md_static, config.color.elements) 206 | .await 207 | } 208 | "explore" => { 209 | let mut matches = matches.clone(); 210 | let selected_note = matches 211 | .remove_many::("select") 212 | .map(|elems| elems.collect::>()); 213 | commands::explore::exec( 214 | db, 215 | selected_note, 216 | config.external_commands, 217 | config.surf_parsing, 218 | md_static, 219 | config.color.elements, 220 | surf_bindings, 221 | checkmark_bindings, 222 | stack_bindings, 223 | explore_bindings, 224 | ) 225 | .await 226 | } 227 | "link" => { 228 | commands::link::exec( 229 | db, 230 | config.external_commands, 231 | config.surf_parsing, 232 | md_static, 233 | config.color.elements, 234 | ) 235 | .await 236 | } 237 | "surf" => { 238 | commands::surf::exec( 239 | db, 240 | config.surf_parsing, 241 | config.external_commands, 242 | md_static, 243 | config.color.elements, 244 | surf_bindings, 245 | explore_bindings, 246 | ) 247 | .await 248 | } 249 | "unlink" => { 250 | commands::unlink::exec( 251 | db, 252 | config.external_commands, 253 | config.surf_parsing, 254 | md_static, 255 | config.color.elements, 256 | ) 257 | .await 258 | } 259 | "remove" => { 260 | commands::remove::exec( 261 | db, 262 | config.external_commands, 263 | config.surf_parsing, 264 | md_static, 265 | config.color.elements, 266 | ) 267 | .await 268 | } 269 | "rename" => { 270 | commands::rename::exec( 271 | db, 272 | config.external_commands, 273 | config.surf_parsing, 274 | md_static, 275 | config.color.elements, 276 | ) 277 | .await 278 | } 279 | "print" => { 280 | let name = matches.get_one::("name").cloned(); 281 | commands::print::exec( 282 | db, 283 | config.external_commands, 284 | config.surf_parsing, 285 | name, 286 | md_static, 287 | config.color.elements, 288 | ) 289 | .await 290 | } 291 | "select" => { 292 | commands::select::exec( 293 | db, 294 | config.external_commands, 295 | config.surf_parsing, 296 | md_static, 297 | config.color.elements, 298 | ) 299 | .await 300 | } 301 | "checkmark" => { 302 | commands::checkmark::exec( 303 | db, 304 | config.surf_parsing, 305 | config.external_commands, 306 | md_static, 307 | config.color.elements, 308 | checkmark_bindings, 309 | explore_bindings, 310 | ) 311 | .await 312 | } 313 | "stack" => { 314 | commands::stack::exec( 315 | db, 316 | vec![], 317 | config.external_commands, 318 | config.surf_parsing, 319 | md_static, 320 | config.color.elements, 321 | stack_bindings, 322 | ) 323 | .await 324 | } 325 | _ => unreachable!("clap should ensure we don't get here"), 326 | } 327 | } 328 | _ => unreachable!("clap should ensure we don't get here"), 329 | }; 330 | result 331 | } 332 | --------------------------------------------------------------------------------