├── .gitignore ├── doc └── demo.gif ├── foucault-server ├── src │ ├── error.rs │ ├── lib.rs │ ├── tag_queries.rs │ ├── tag_api.rs │ ├── notebook.rs │ ├── note_queries.rs │ └── note_api.rs ├── migrations │ ├── 20240817093147_notes_table.sql │ ├── 20240817093700_tags_table.sql │ ├── 20240817090817_links_table.sql │ └── 20240817093924_tags_join_table.sql └── Cargo.toml ├── foucault-core ├── src │ ├── link_repr.rs │ ├── permissions.rs │ ├── lib.rs │ ├── tag_repr.rs │ ├── note_repr.rs │ ├── pretty_error.rs │ └── api.rs └── Cargo.toml ├── .github └── workflows │ ├── build-setup.yml │ ├── rust.yml │ └── release.yml ├── foucault-client ├── src │ ├── links.rs │ ├── response_error.rs │ ├── states │ │ ├── error.rs │ │ ├── note_tag_deletion.rs │ │ ├── tag_deletion.rs │ │ ├── tag_creation.rs │ │ ├── note_renaming.rs │ │ ├── tag_renaming.rs │ │ ├── note_creation.rs │ │ ├── nothing.rs │ │ ├── note_tag_addition.rs │ │ ├── note_deletion.rs │ │ ├── note_tags_managing.rs │ │ ├── tag_notes_listing.rs │ │ ├── tags_managing.rs │ │ └── notes_managing.rs │ ├── lib.rs │ ├── explore.rs │ ├── tag.rs │ ├── markdown.rs │ ├── states.rs │ ├── helpers.rs │ ├── note.rs │ └── markdown │ │ └── elements.rs └── Cargo.toml ├── justfile ├── Cargo.toml ├── README.md ├── src ├── notebook_selector.rs └── main.rs └── wix └── main.wxs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /build 3 | /foucault-server/.sqlx 4 | log.txt 5 | -------------------------------------------------------------------------------- /doc/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Adi-df/foucault/HEAD/doc/demo.gif -------------------------------------------------------------------------------- /foucault-server/src/error.rs: -------------------------------------------------------------------------------- 1 | use axum::{http::StatusCode, Json}; 2 | 3 | pub type FailibleJsonResult = Result<(StatusCode, Json), StatusCode>; 4 | -------------------------------------------------------------------------------- /foucault-server/migrations/20240817093147_notes_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE notes_table ( 2 | id integer PRIMARY KEY AUTOINCREMENT, 3 | name text UNIQUE, 4 | content text 5 | ); 6 | -------------------------------------------------------------------------------- /foucault-server/migrations/20240817093700_tags_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE tags_table ( 2 | id integer PRIMARY KEY AUTOINCREMENT, 3 | name text UNIQUE NOT NULL, 4 | color integer NOT NULL 5 | ); 6 | -------------------------------------------------------------------------------- /foucault-core/src/link_repr.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 4 | pub struct Link { 5 | pub from: i64, 6 | pub to: String, 7 | } 8 | -------------------------------------------------------------------------------- /foucault-server/migrations/20240817090817_links_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE links_table ( 2 | id integer PRIMARY KEY AUTOINCREMENT, 3 | from_id integer NOT NULL, 4 | to_name text NOT NULL, 5 | FOREIGN KEY (from_id) REFERENCES notes_table (id) 6 | ON UPDATE CASCADE ON DELETE CASCADE 7 | ); 8 | -------------------------------------------------------------------------------- /.github/workflows/build-setup.yml: -------------------------------------------------------------------------------- 1 | # This file is used by cargo-dist to prepare SQLx compile-time queries 2 | 3 | - uses: extractions/setup-just@v2 4 | - name: Install SQLx-cli 5 | run: cargo install sqlx-cli --no-default-features --features sqlite 6 | - name: Prepare queries 7 | run: just prepare-queries 8 | -------------------------------------------------------------------------------- /foucault-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "foucault-core" 3 | version = "0.2.2" 4 | edition = "2021" 5 | license = "GPL-3.0" 6 | authors = ["Adrien Degliame--Fecchio "] 7 | repository = "https://github.com/Adi-df/foucault/" 8 | 9 | [dependencies] 10 | colored = "3.0.0" 11 | thiserror = "2.0.12" 12 | serde = { version = "1.0.209", features = ["derive"] } 13 | -------------------------------------------------------------------------------- /foucault-server/migrations/20240817093924_tags_join_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE tags_join_table ( 2 | id integer PRIMARY KEY AUTOINCREMENT, 3 | note_id integer NOT NULL, 4 | tag_id integer NOT NULL, 5 | FOREIGN KEY (note_id) REFERENCES notes_table (id) 6 | ON UPDATE CASCADE ON DELETE CASCADE, 7 | FOREIGN KEY (tag_id) REFERENCES tags_table (id) 8 | ON UPDATE CASCADE ON DELETE CASCADE 9 | ); 10 | -------------------------------------------------------------------------------- /foucault-core/src/permissions.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Copy, Serialize, Deserialize)] 4 | pub enum Permissions { 5 | ReadWrite, 6 | ReadOnly, 7 | } 8 | 9 | impl Permissions { 10 | #[must_use] 11 | pub fn writable(&self) -> bool { 12 | match self { 13 | Permissions::ReadWrite => true, 14 | Permissions::ReadOnly => false, 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /foucault-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | pub mod link_repr; 3 | pub mod note_repr; 4 | pub mod permissions; 5 | pub mod pretty_error; 6 | pub mod tag_repr; 7 | 8 | pub use pretty_error::PrettyError; 9 | 10 | use serde::{Deserialize, Serialize}; 11 | 12 | use crate::permissions::Permissions; 13 | 14 | #[derive(Debug, Clone, Serialize, Deserialize)] 15 | pub struct NotebookApiInfo { 16 | pub name: String, 17 | pub permissions: Permissions, 18 | } 19 | -------------------------------------------------------------------------------- /foucault-client/src/links.rs: -------------------------------------------------------------------------------- 1 | use foucault_core::link_repr; 2 | 3 | #[derive(Debug, Clone, PartialEq, Eq)] 4 | pub struct Link { 5 | inner: link_repr::Link, 6 | } 7 | 8 | impl From for Link { 9 | fn from(inner: link_repr::Link) -> Self { 10 | Self { inner } 11 | } 12 | } 13 | 14 | impl Link { 15 | pub fn new(from: i64, to: String) -> Self { 16 | Link { 17 | inner: link_repr::Link { from, to }, 18 | } 19 | } 20 | 21 | pub fn get_inner(&self) -> &link_repr::Link { 22 | &self.inner 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /foucault-core/src/tag_repr.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use thiserror::Error; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize)] 7 | pub struct Tag { 8 | pub id: i64, 9 | pub name: Arc, 10 | pub color: u32, 11 | } 12 | 13 | #[derive(Debug, Clone, Copy, Error, Serialize, Deserialize)] 14 | pub enum TagError { 15 | #[error("A similarly named tag already exists.")] 16 | AlreadyExists, 17 | #[error("The provided tag name is empty.")] 18 | EmptyName, 19 | #[error("No such tag exists.")] 20 | DoesNotExists, 21 | } 22 | -------------------------------------------------------------------------------- /foucault-server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "foucault-server" 3 | version = "0.4.1" 4 | edition = "2021" 5 | license = "GPL-3.0" 6 | authors = ["Adrien Degliame--Fecchio "] 7 | repository = "https://github.com/Adi-df/foucault/" 8 | 9 | [dependencies] 10 | foucault-core = { path = "../foucault-core" } 11 | axum = "0.8.3" 12 | anyhow = "1.0.86" 13 | futures = "0.3.30" 14 | thiserror = "2.0.12" 15 | serde-error = "0.1.2" 16 | random_color = "1.0.0" 17 | log = { version = "0.4.22", features = ["std"] } 18 | tokio = { version = "1.40.0", features = ["full"] } 19 | sqlx = { version = "0.8.1", features = ["runtime-tokio", "migrate", "macros", "sqlite"] } 20 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | default: 2 | just --list 3 | 4 | mock-db-filename := "mock.db" 5 | mock-db-filepath := join(justfile_dir(), mock-db-filename) 6 | mock-db-url := "sqlite:///" + trim_start_match(mock-db-filepath, "/") + "?mode=rwc" 7 | prepare-queries: 8 | @echo "Prepare SQLx compile-time queries." 9 | cd foucault-server && env DATABASE_URL="{{mock-db-url}}" cargo sqlx migrate run 10 | cd foucault-server && env DATABASE_URL="{{mock-db-url}}" cargo sqlx prepare 11 | rm "{{mock-db-filepath}}" 12 | 13 | prepare-dist: 14 | cargo dist init 15 | cargo dist plan 16 | 17 | build-dev: 18 | cargo build 19 | 20 | build-release: prepare-queries 21 | cargo build --release 22 | -------------------------------------------------------------------------------- /foucault-client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "foucault-client" 3 | version = "0.5.3" 4 | edition = "2021" 5 | license = "GPL-3.0" 6 | authors = ["Adrien Degliame--Fecchio "] 7 | repository = "https://github.com/Adi-df/foucault/" 8 | 9 | [dependencies] 10 | foucault-core = { path = "../foucault-core" } 11 | dirs = "6.0.0" 12 | edit = "0.1.5" 13 | opener = "0.7.2" 14 | anyhow = "1.0.86" 15 | ratatui = "0.29.0" 16 | textwrap = "0.16.1" 17 | thiserror = "2.0.12" 18 | scopeguard = "1.2.0" 19 | crossterm = "0.29.0" 20 | serde-error = "0.1.2" 21 | markdown = "1.0.0-alpha.20" 22 | unicode-segmentation = "1.11.0" 23 | log = { version = "0.4.22", features = ["std"] } 24 | tokio = { version = "1.40.0", features = ["full"] } 25 | reqwest = { version = "0.12.7", features = ["json"] } 26 | -------------------------------------------------------------------------------- /foucault-core/src/note_repr.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use thiserror::Error; 4 | 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use crate::tag_repr::Tag; 8 | 9 | #[derive(Debug, Clone, Copy, Error, Serialize, Deserialize)] 10 | pub enum NoteError { 11 | #[error("No such note exists.")] 12 | DoesNotExist, 13 | #[error("A similarly named note already exists.")] 14 | AlreadyExists, 15 | #[error("The provided note name is empty.")] 16 | EmptyName, 17 | #[error("The note already has the provided tag.")] 18 | NoteAlreadyTagged, 19 | } 20 | 21 | #[derive(Debug, Clone, Serialize, Deserialize)] 22 | pub struct Note { 23 | pub id: i64, 24 | pub name: Arc, 25 | pub content: Arc, 26 | } 27 | 28 | #[derive(Debug, Clone, Serialize, Deserialize)] 29 | pub struct NoteSummary { 30 | pub id: i64, 31 | pub name: Arc, 32 | pub tags: Vec, 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | RUSTFALGS: "-Dwarnings" 12 | 13 | jobs: 14 | clippy: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: extractions/setup-just@v2 20 | - name: Install SQLx-cli 21 | run: cargo install sqlx-cli --no-default-features --features sqlite 22 | - name: Prepare queries 23 | run: just prepare-queries 24 | - name: Clippy Check 25 | run: cargo clippy --all-targets --all-features 26 | 27 | build: 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - uses: actions/checkout@v3 32 | - uses: extractions/setup-just@v2 33 | - name: Install SQLx-cli 34 | run: cargo install sqlx-cli --no-default-features --features sqlite 35 | - name: Prepare queries 36 | run: just prepare-queries 37 | - name: Build 38 | run: cargo build --verbose 39 | - name: Run tests 40 | run: cargo test --verbose 41 | -------------------------------------------------------------------------------- /foucault-client/src/response_error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | use reqwest::{Response, StatusCode}; 4 | 5 | #[derive(Debug, Error)] 6 | pub enum ResponseCodeError { 7 | #[error("Such an operation isn't allowed.")] 8 | Unauthorized, 9 | #[error("The server encountered an internal error.")] 10 | InternalServerError, 11 | #[error("Unexpected response code : {0}.")] 12 | UnexpectedCode(StatusCode), 13 | } 14 | 15 | pub trait TryResponseCode { 16 | fn try_response_code(self) -> Result 17 | where 18 | Self: Sized; 19 | } 20 | 21 | impl TryResponseCode for Response { 22 | fn try_response_code(self) -> Result 23 | where 24 | Self: Sized, 25 | { 26 | match self.status() { 27 | StatusCode::OK | StatusCode::NOT_ACCEPTABLE => Ok(self), 28 | StatusCode::UNAUTHORIZED => Err(ResponseCodeError::Unauthorized), 29 | StatusCode::INTERNAL_SERVER_ERROR => Err(ResponseCodeError::InternalServerError), 30 | status => Err(ResponseCodeError::UnexpectedCode(status)), 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /foucault-core/src/pretty_error.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Display, panic}; 2 | 3 | use colored::Colorize; 4 | 5 | #[macro_export] 6 | macro_rules! pretty_error { 7 | () => { 8 | $crate::pretty_error::pretty_error("An error occured"); 9 | }; 10 | ($($arg:tt)*) => {{ 11 | $crate::pretty_error::pretty_error(&format!($($arg)*)); 12 | }}; 13 | } 14 | 15 | #[doc(hidden)] 16 | pub fn pretty_error(err: &str) { 17 | eprintln!("{} : {err}", "error".red().bold()); 18 | } 19 | 20 | pub trait PrettyError { 21 | type Item; 22 | fn pretty_unwrap(self) -> Self::Item; 23 | } 24 | 25 | impl PrettyError for Result 26 | where 27 | E: Display, 28 | { 29 | type Item = T; 30 | fn pretty_unwrap(self) -> Self::Item { 31 | match self { 32 | Ok(val) => val, 33 | Err(err) => { 34 | pretty_error(&format!("{err}")); 35 | panic::resume_unwind(Box::new(1)); 36 | } 37 | } 38 | } 39 | } 40 | 41 | impl PrettyError for Option { 42 | type Item = T; 43 | fn pretty_unwrap(self) -> Self::Item { 44 | if let Some(val) = self { 45 | val 46 | } else { 47 | pretty_error("Unwrapped an empty Option."); 48 | panic::resume_unwind(Box::new(1)); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /foucault-core/src/api.rs: -------------------------------------------------------------------------------- 1 | pub mod note { 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::link_repr::Link; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize)] 7 | pub struct CreateParam { 8 | pub name: String, 9 | pub content: String, 10 | } 11 | 12 | #[derive(Debug, Clone, Serialize, Deserialize)] 13 | pub struct RenameParam { 14 | pub id: i64, 15 | pub name: String, 16 | } 17 | 18 | #[derive(Debug, Clone, Serialize, Deserialize)] 19 | pub struct UpdateContentParam { 20 | pub id: i64, 21 | pub content: String, 22 | } 23 | 24 | #[derive(Debug, Clone, Serialize, Deserialize)] 25 | pub struct UpdateLinksParam { 26 | pub id: i64, 27 | pub links: Vec, 28 | } 29 | 30 | #[derive(Debug, Clone, Serialize, Deserialize)] 31 | pub struct ValidateNewTagParam { 32 | pub id: i64, 33 | pub tag_id: i64, 34 | } 35 | 36 | #[derive(Debug, Clone, Serialize, Deserialize)] 37 | pub struct AddTagParam { 38 | pub id: i64, 39 | pub tag_id: i64, 40 | } 41 | 42 | #[derive(Debug, Clone, Serialize, Deserialize)] 43 | pub struct RemoveTagParam { 44 | pub id: i64, 45 | pub tag_id: i64, 46 | } 47 | 48 | #[derive(Debug, Clone, Serialize, Deserialize)] 49 | pub struct SearchWithTagParam { 50 | pub tag_id: i64, 51 | pub pattern: String, 52 | } 53 | } 54 | 55 | pub mod tag { 56 | use serde::{Deserialize, Serialize}; 57 | 58 | #[derive(Debug, Clone, Serialize, Deserialize)] 59 | pub struct RenameParam { 60 | pub id: i64, 61 | pub name: String, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["foucault-client", "foucault-core", "foucault-server"] 3 | 4 | [package] 5 | name = "foucault" 6 | version = "0.3.4" 7 | edition = "2021" 8 | license = "GPL-3.0" 9 | authors = ["Adrien Degliame--Fecchio "] 10 | repository = "https://github.com/Adi-df/foucault/" 11 | description = "A minimal TUI note taking app" 12 | 13 | [dependencies] 14 | foucault-core = { path = "foucault-core" } 15 | foucault-client = { path = "foucault-client" } 16 | foucault-server = { path = "foucault-server" } 17 | anyhow = "1.0.86" 18 | question = "0.2.2" 19 | ratatui = "0.29.0" 20 | thiserror = "2.0.12" 21 | scopeguard = "1.2.0" 22 | crossterm = "0.29.0" 23 | env_logger = "0.11.5" 24 | log = { version = "0.4.22", features = ["std"] } 25 | tokio = { version = "1.40.0", features = ["full"] } 26 | clap = { version = "4.5.16", features = ["derive", "cargo"] } 27 | 28 | [package.metadata.wix] 29 | upgrade-guid = "49E7C3B1-F9F9-4344-A3B8-2AE27FC5724B" 30 | path-guid = "E5D1C3D7-1E27-4590-8F0F-DF9E159067DC" 31 | license = false 32 | eula = false 33 | 34 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 35 | 36 | [profile.release] 37 | lto = "fat" 38 | strip = true 39 | codegen-units = 1 40 | 41 | # The profile that 'cargo dist' will build with 42 | [profile.dist] 43 | inherits = "release" 44 | 45 | # Config for 'cargo dist' 46 | [workspace.metadata.dist] 47 | # The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) 48 | cargo-dist-version = "0.21.1" 49 | # CI backends to support 50 | ci = "github" 51 | # The installers to generate for each app 52 | installers = ["shell", "powershell", "msi"] 53 | # Target platforms to build apps for (Rust target-triple syntax) 54 | targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] 55 | # Path that installers should place binaries in 56 | install-path = "CARGO_HOME" 57 | # Whether to install an updater program 58 | install-updater = false 59 | # Run the prepare-queries task as part of the build setup 60 | github-build-setup = "build-setup.yml" 61 | -------------------------------------------------------------------------------- /foucault-client/src/states/error.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use log::info; 3 | 4 | use crossterm::event::{KeyCode, KeyEvent}; 5 | use ratatui::{ 6 | layout::{Constraint, Rect}, 7 | style::{Color, Style}, 8 | widgets::{Block, BorderType, Borders, Clear, Paragraph}, 9 | Frame, 10 | }; 11 | 12 | use crate::{helpers::create_popup, states::State, NotebookAPI}; 13 | 14 | #[derive(Clone)] 15 | pub struct ErrorStateData { 16 | inner_state: Box, 17 | error_message: String, 18 | } 19 | 20 | impl ErrorStateData { 21 | pub fn new(state: State, error_message: String) -> Self { 22 | Self { 23 | inner_state: Box::new(state), 24 | error_message, 25 | } 26 | } 27 | } 28 | 29 | pub async fn run_error_state(state_data: ErrorStateData, key_event: KeyEvent) -> Result { 30 | Ok(match key_event.code { 31 | KeyCode::Char('q') => { 32 | info!("Quit foucault."); 33 | State::Exit 34 | } 35 | KeyCode::Enter => { 36 | info!("Close the error popup."); 37 | *state_data.inner_state 38 | } 39 | _ => State::Error(state_data), 40 | }) 41 | } 42 | 43 | pub fn draw_error_state( 44 | notebook: &NotebookAPI, 45 | state_data: &ErrorStateData, 46 | frame: &mut Frame, 47 | main_rect: Rect, 48 | ) { 49 | state_data.inner_state.draw(notebook, frame, main_rect); 50 | 51 | let line_width = main_rect.width * 80 / 100; 52 | let wrapped_text = textwrap::wrap(state_data.error_message.as_str(), line_width as usize); 53 | let line_count = wrapped_text.len(); 54 | 55 | let popup_area = create_popup( 56 | ( 57 | Constraint::Length(80), 58 | Constraint::Length(u16::try_from(line_count + 2).unwrap()), 59 | ), 60 | main_rect, 61 | ); 62 | let err_popup = Paragraph::new(wrapped_text.join("\n")).block( 63 | Block::new() 64 | .title("Error") 65 | .borders(Borders::ALL) 66 | .border_type(BorderType::Double) 67 | .border_style(Style::new().fg(Color::Red)), 68 | ); 69 | 70 | frame.render_widget(Clear, popup_area); 71 | frame.render_widget(err_popup, popup_area); 72 | } 73 | -------------------------------------------------------------------------------- /foucault-client/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(clippy::pedantic)] 2 | #![warn(unused_crate_dependencies)] 3 | #![allow(clippy::too_many_lines)] 4 | #![allow(clippy::missing_panics_doc)] 5 | #![allow(clippy::missing_errors_doc)] 6 | #![allow(clippy::module_name_repetitions)] 7 | 8 | pub mod explore; 9 | mod helpers; 10 | mod links; 11 | mod markdown; 12 | mod note; 13 | mod response_error; 14 | mod states; 15 | mod tag; 16 | 17 | use std::{path::PathBuf, sync::LazyLock}; 18 | 19 | use anyhow::Result; 20 | use thiserror::Error; 21 | 22 | use reqwest::Client; 23 | 24 | use foucault_core::{permissions::Permissions, pretty_error, NotebookApiInfo}; 25 | 26 | use crate::response_error::TryResponseCode; 27 | 28 | pub static APP_DIR_PATH: LazyLock = LazyLock::new(|| { 29 | if let Some(data_dir) = dirs::data_dir() { 30 | data_dir.join("foucault") 31 | } else { 32 | pretty_error!("The user data directory is unavailable."); 33 | unimplemented!(); 34 | } 35 | }); 36 | 37 | #[derive(Debug, Error)] 38 | pub enum ApiError { 39 | #[error("Unable to connect to the remote endpoint : {0}")] 40 | UnableToConnect(reqwest::Error), 41 | #[error("Unable to ping the notebook informations : {0}")] 42 | UnableToPingInfos(reqwest::Error), 43 | #[error("Unable to contact the remote notebook : {0}")] 44 | UnableToContactRemoteNotebook(reqwest::Error), 45 | #[error("Unable to parse the request result : {0}")] 46 | UnableToParseResponse(reqwest::Error), 47 | } 48 | 49 | pub struct NotebookAPI { 50 | pub name: String, 51 | pub permissions: Permissions, 52 | pub endpoint: String, 53 | pub client: Client, 54 | } 55 | 56 | impl NotebookAPI { 57 | pub async fn new(endpoint: String) -> Result { 58 | let NotebookApiInfo { name, permissions } = reqwest::get(format!("{endpoint}/notebook")) 59 | .await 60 | .map_err(ApiError::UnableToConnect)? 61 | .try_response_code()? 62 | .json::() 63 | .await 64 | .map_err(ApiError::UnableToPingInfos)?; 65 | 66 | Ok(Self { 67 | name, 68 | permissions, 69 | endpoint, 70 | client: Client::new(), 71 | }) 72 | } 73 | 74 | #[must_use] 75 | pub fn build_url(&self, path: &str) -> String { 76 | format!("{}{}", self.endpoint, path) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /foucault-client/src/explore.rs: -------------------------------------------------------------------------------- 1 | use std::{io::stdout, time::Duration}; 2 | 3 | use anyhow::Result; 4 | use log::info; 5 | use scopeguard::defer; 6 | 7 | use crossterm::{ 8 | event::{self, Event, KeyEventKind}, 9 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 10 | ExecutableCommand, 11 | }; 12 | 13 | use ratatui::{ 14 | prelude::CrosstermBackend, 15 | style::{Color, Style}, 16 | widgets::{Block, BorderType, Borders, Clear, Padding}, 17 | Terminal, 18 | }; 19 | 20 | use crate::{ 21 | states::{error::ErrorStateData, State}, 22 | NotebookAPI, 23 | }; 24 | 25 | pub async fn explore(notebook: &NotebookAPI) -> Result<()> { 26 | info!("Explore notebook : {}", notebook.name); 27 | 28 | enable_raw_mode().expect("Prepare terminal"); 29 | stdout() 30 | .execute(EnterAlternateScreen) 31 | .expect("Prepare terminal"); 32 | 33 | defer! { 34 | stdout().execute(LeaveAlternateScreen).expect("Reset terminal"); 35 | disable_raw_mode().expect("Reset terminal"); 36 | } 37 | 38 | let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; 39 | let mut forced_redraw = false; 40 | 41 | let mut state = State::Nothing; 42 | 43 | loop { 44 | { 45 | if event::poll(Duration::from_millis(50))? { 46 | if let Event::Key(key) = event::read()? { 47 | if key.kind == KeyEventKind::Press { 48 | let run_state = state.clone(); 49 | match run_state.run(key, notebook, &mut forced_redraw).await { 50 | Ok(new_state) => state = new_state, 51 | Err(err) => { 52 | state = State::Error(ErrorStateData::new(state, err.to_string())); 53 | } 54 | } 55 | } 56 | } 57 | } 58 | 59 | if matches!(state, State::Exit) { 60 | break; 61 | } 62 | } 63 | 64 | { 65 | if forced_redraw { 66 | terminal.draw(|frame| { 67 | frame.render_widget(Clear, frame.area()); 68 | })?; 69 | forced_redraw = false; 70 | } 71 | terminal.draw(|frame| { 72 | let main_frame = Block::new() 73 | .title(notebook.name.as_str()) 74 | .padding(Padding::uniform(1)) 75 | .borders(Borders::all()) 76 | .border_type(BorderType::Rounded) 77 | .border_style(Style::new().fg(Color::White)); 78 | 79 | let main_rect = main_frame.inner(frame.area()); 80 | 81 | state.draw(notebook, frame, main_rect); 82 | frame.render_widget(main_frame, frame.area()); 83 | })?; 84 | } 85 | } 86 | 87 | Ok(()) 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Foucault 2 | A small terminal UI note-taking app. 3 | 4 | ![Demo](doc/demo.gif) 5 | 6 | Note : Demo made using the [Helix](https://github.com/helix-editor/helix) editor for editing notes and scraped data from Wikipedia to fill the notebook. 7 | 8 | # Install Foucault 9 | 10 | ## Shell Installer 11 | 12 | Thanks to [cargo-dist](https://github.com/axodotdev/cargo-dist), an installer script exists to install foucault with just one command. 13 | 14 | ```sh 15 | curl --proto '=https' --tlsv1.2 -LsSf https://github.com/Adi-df/foucault/releases/download/v0.3.3/foucault-installer.sh | sh 16 | ``` 17 | 18 | ## Binaries 19 | 20 | Look through [Releases](https://github.com/Adi-df/foucault/releases) for binaries and MSI installers. 21 | 22 | ## Building from source 23 | 24 | The easiest way to build foucault from source is to use the [just](https://github.com/casey/just) command runner. 25 | 26 | ```sh 27 | # Clone the foucault repo 28 | git clone https://github.com/Adi-df/foucault 29 | # Use build-release to build using optimisation 30 | just build-release 31 | # Use prepare-queries and build-dev to build using the dev profile 32 | just prepare-queries 33 | just build-dev 34 | ``` 35 | 36 | # Usage 37 | 38 | ## Creating your first notebook 39 | 40 | Foucault is based on the notion of Notebook which contains notes, tags, etc. 41 | It exposes a CLI app made with [clap](https://github.com/clap-rs/clap) to manage notebooks. 42 | To create your first notebook, use `foucault create [NAME]`. 43 | Then open it with `foucault open [NAME]`. 44 | 45 | ## Using foucault to take notes 46 | 47 | The keymap is detailed when toggling the help bar with `CTRL+H`. 48 | 49 | Editing notes works with an external editor set by the `EDITOR` env variable, so that you may use your favorite editor. 50 | Notes are taken in Markdown with (limited) support. It supports making cross-references between notes by using `[[NOTE_NAME]]`. 51 | 52 | ## Exposing a notebook / Connecting to one 53 | 54 | Foucault supports accessing remote notebooks : Expose the notebook through `foucault serve [NAME]`. And connect to it with `foucault connect http://remotenotebookadress.org`. 55 | 56 | # Is it any good ? 57 | 58 | Probably not. 59 | 60 | Foucault is just a side project I made to take notes in philosophy courses. 61 | There are probably plenty of bugs, it's inefficient and missing a lot of features. 62 | But it still kinda works, so maybe take a look ! 63 | 64 | # Built with 65 | 66 | - The fantastic Rust language. 67 | - The smashing [clap](https://github.com/clap-rs/clap) command parser. 68 | - The amazing [Tokio](https://github.com/tokio-rs/tokio) async runtime. 69 | - The wonderful [SQLite](https://www.sqlite.org/) database through the brilliant [SQLx](https://github.com/launchbadge/sqlx) library. 70 | - The awesome [axum](https://github.com/tokio-rs/axum) web framework. 71 | - The incredible [ratatui](https://github.com/ratatui-org/ratatui) TUI library. 72 | - The terrific [just](https://github.com/casey/just) command runner. 73 | - The superb [cargo-dist](https://github.com/axodotdev/cargo-dist) app packager. 74 | - And many other excellent open source crate. 75 | -------------------------------------------------------------------------------- /foucault-server/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(clippy::pedantic)] 2 | #![warn(unused_crate_dependencies)] 3 | #![allow(clippy::too_many_lines)] 4 | #![allow(clippy::missing_panics_doc)] 5 | #![allow(clippy::missing_errors_doc)] 6 | #![allow(clippy::module_name_repetitions)] 7 | 8 | mod error; 9 | mod note_api; 10 | mod note_queries; 11 | pub mod notebook; 12 | mod tag_api; 13 | mod tag_queries; 14 | 15 | use std::sync::Arc; 16 | 17 | use anyhow::Result; 18 | use thiserror::Error; 19 | 20 | use axum::{ 21 | extract::State, 22 | http::StatusCode, 23 | routing::{delete, get, patch, post}, 24 | Json, Router, 25 | }; 26 | use tokio::{io, net::TcpListener}; 27 | 28 | use foucault_core::{permissions::Permissions, NotebookApiInfo}; 29 | 30 | use crate::notebook::Notebook; 31 | 32 | #[derive(Debug, Error)] 33 | pub enum ServerError { 34 | #[error("Unable to bind the listener : {0}")] 35 | UnableToBindListener(io::Error), 36 | #[error("An internal server error occured : {0}")] 37 | InternalServerError(io::Error), 38 | } 39 | 40 | #[derive(Clone)] 41 | struct AppState { 42 | notebook: Arc, 43 | permissions: Permissions, 44 | } 45 | 46 | pub async fn serve(notebook: Arc, permissions: Permissions, port: u16) -> Result<()> { 47 | let state = AppState { 48 | notebook, 49 | permissions, 50 | }; 51 | let app = Router::new() 52 | .route("/notebook", get(notebook_info)) 53 | .route("/note/create", post(note_api::create)) 54 | .route("/note/delete", delete(note_api::delete)) 55 | .route("/note/validate/name", get(note_api::validate_name)) 56 | .route("/note/validate/tag", get(note_api::validate_new_tag)) 57 | .route("/note/load/id", get(note_api::load_by_id)) 58 | .route("/note/load/name", get(note_api::load_by_name)) 59 | .route("/note/search/name", get(note_api::search_by_name)) 60 | .route("/note/search/tag", get(note_api::search_with_tag)) 61 | .route("/note/update/name", patch(note_api::rename)) 62 | .route("/note/update/content", patch(note_api::update_content)) 63 | .route("/note/update/links", patch(note_api::update_links)) 64 | .route("/note/tag/list", get(note_api::list_tags)) 65 | .route("/note/tag/add", post(note_api::add_tag)) 66 | .route("/note/tag/remove", delete(note_api::remove_tag)) 67 | .route("/tag/create", post(tag_api::create)) 68 | .route("/tag/delete", delete(tag_api::delete)) 69 | .route("/tag/validate/name", get(tag_api::validate_name)) 70 | .route("/tag/load/name", get(tag_api::load_by_name)) 71 | .route("/tag/search/name", get(tag_api::search_by_name)) 72 | .route("/tag/update/name", patch(tag_api::rename)) 73 | .with_state(state); 74 | 75 | let address = format!("0.0.0.0:{port}"); 76 | let listener = TcpListener::bind(&address) 77 | .await 78 | .map_err(ServerError::UnableToBindListener)?; 79 | axum::serve(listener, app) 80 | .await 81 | .map_err(ServerError::InternalServerError)?; 82 | 83 | Ok(()) 84 | } 85 | 86 | async fn notebook_info(State(state): State) -> (StatusCode, Json) { 87 | ( 88 | StatusCode::OK, 89 | Json::from(NotebookApiInfo { 90 | name: state.notebook.name.clone(), 91 | permissions: state.permissions, 92 | }), 93 | ) 94 | } 95 | -------------------------------------------------------------------------------- /foucault-client/src/states/note_tag_deletion.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use log::info; 3 | 4 | use crossterm::event::{KeyCode, KeyEvent}; 5 | use ratatui::{layout::Rect, Frame}; 6 | 7 | use crate::{ 8 | helpers::draw_yes_no_prompt, 9 | states::{ 10 | note_tags_managing::{draw_note_tags_managing_state, NoteTagsManagingStateData}, 11 | State, 12 | }, 13 | NotebookAPI, 14 | }; 15 | 16 | #[derive(Clone)] 17 | pub struct NoteTagDeletionStateData { 18 | note_tags_managing_data: NoteTagsManagingStateData, 19 | delete: bool, 20 | } 21 | 22 | impl NoteTagDeletionStateData { 23 | pub fn empty(note_tags_managing_data: NoteTagsManagingStateData) -> Self { 24 | NoteTagDeletionStateData { 25 | note_tags_managing_data, 26 | delete: false, 27 | } 28 | } 29 | } 30 | 31 | pub async fn run_note_tag_deletion_state( 32 | NoteTagDeletionStateData { 33 | mut note_tags_managing_data, 34 | delete, 35 | }: NoteTagDeletionStateData, 36 | key_event: KeyEvent, 37 | notebook: &NotebookAPI, 38 | ) -> Result { 39 | Ok(match key_event.code { 40 | KeyCode::Esc => { 41 | info!( 42 | "Cancel the removal of tag {} from note {}", 43 | note_tags_managing_data 44 | .get_selected() 45 | .expect("A tag should be selected.") 46 | .name(), 47 | note_tags_managing_data.note.name() 48 | ); 49 | State::NoteTagsManaging( 50 | NoteTagsManagingStateData::new(note_tags_managing_data.note, notebook).await?, 51 | ) 52 | } 53 | KeyCode::Enter => { 54 | if delete { 55 | let tag = note_tags_managing_data 56 | .get_selected() 57 | .expect("A tag to be selected"); 58 | 59 | info!( 60 | "Remove tag {} from note {}.", 61 | tag.name(), 62 | note_tags_managing_data.note.name() 63 | ); 64 | 65 | note_tags_managing_data 66 | .note 67 | .remove_tag(tag.id(), notebook) 68 | .await?; 69 | 70 | State::NoteTagsManaging( 71 | NoteTagsManagingStateData::new(note_tags_managing_data.note, notebook).await?, 72 | ) 73 | } else { 74 | State::NoteTagsManaging( 75 | NoteTagsManagingStateData::new(note_tags_managing_data.note, notebook).await?, 76 | ) 77 | } 78 | } 79 | KeyCode::Tab => State::NoteTagDeletion(NoteTagDeletionStateData { 80 | note_tags_managing_data, 81 | delete: !delete, 82 | }), 83 | _ => State::NoteTagDeletion(NoteTagDeletionStateData { 84 | note_tags_managing_data, 85 | delete, 86 | }), 87 | }) 88 | } 89 | 90 | pub fn draw_note_tag_deletion_state( 91 | NoteTagDeletionStateData { 92 | note_tags_managing_data, 93 | delete, 94 | }: &NoteTagDeletionStateData, 95 | notebook: &NotebookAPI, 96 | frame: &mut Frame, 97 | main_rect: Rect, 98 | ) { 99 | draw_note_tags_managing_state(note_tags_managing_data, notebook, frame, main_rect); 100 | draw_yes_no_prompt(frame, *delete, "Remove tag ?", main_rect); 101 | } 102 | -------------------------------------------------------------------------------- /foucault-server/src/tag_queries.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::Result; 4 | 5 | use random_color::{options::Luminosity, RandomColor}; 6 | 7 | use sqlx::SqlitePool; 8 | 9 | use foucault_core::tag_repr::{Tag, TagError}; 10 | 11 | fn rand_color() -> u32 { 12 | let [r, g, b] = RandomColor::new() 13 | .luminosity(Luminosity::Bright) 14 | .to_rgb_array(); 15 | (u32::from(r) << 16) + (u32::from(g) << 8) + u32::from(b) 16 | } 17 | 18 | pub(crate) async fn create(name: String, connection: &SqlitePool) -> Result { 19 | if let Some(err) = validate_name(&name, connection).await? { 20 | return Err(err.into()); 21 | }; 22 | 23 | let color = rand_color(); 24 | let id = sqlx::query!( 25 | "INSERT INTO tags_table (name, color) VALUES ($1, $2) RETURNING id", 26 | name, 27 | color 28 | ) 29 | .fetch_one(connection) 30 | .await? 31 | .id; 32 | 33 | Ok(Tag { 34 | id, 35 | name: Arc::from(name), 36 | color, 37 | }) 38 | } 39 | 40 | pub(crate) async fn validate_name(name: &str, connection: &SqlitePool) -> Result> { 41 | if name.is_empty() { 42 | Ok(Some(TagError::EmptyName)) 43 | } else if name_exists(name, connection).await? { 44 | Ok(Some(TagError::AlreadyExists)) 45 | } else { 46 | Ok(None) 47 | } 48 | } 49 | 50 | pub(crate) async fn id_exists(id: i64, connection: &SqlitePool) -> Result { 51 | Ok(sqlx::query!("SELECT id FROM tags_table WHERE id=$1", id) 52 | .fetch_optional(connection) 53 | .await? 54 | .is_some()) 55 | } 56 | 57 | pub(crate) async fn name_exists(name: &str, connection: &SqlitePool) -> Result { 58 | Ok( 59 | sqlx::query!("SELECT id FROM tags_table WHERE name=$1", name) 60 | .fetch_optional(connection) 61 | .await? 62 | .is_some(), 63 | ) 64 | } 65 | 66 | pub(crate) async fn load_by_name(name: String, connection: &SqlitePool) -> Result> { 67 | sqlx::query!("SELECT id,color FROM tags_table WHERE name=$1", name) 68 | .fetch_optional(connection) 69 | .await? 70 | .map(|row| { 71 | Ok(Tag { 72 | id: row.id.expect("There should be a tag id"), 73 | name: Arc::from(name), 74 | color: u32::try_from(row.color)?, 75 | }) 76 | }) 77 | .transpose() 78 | } 79 | 80 | pub(crate) async fn search_by_name(pattern: &str, connection: &SqlitePool) -> Result> { 81 | let sql_pattern = format!("%{pattern}%"); 82 | sqlx::query!( 83 | "SELECT id,name,color FROM tags_table WHERE name LIKE $1 ORDER BY name ASC", 84 | sql_pattern 85 | ) 86 | .fetch_all(connection) 87 | .await? 88 | .into_iter() 89 | .map(|row| { 90 | Ok(Tag { 91 | id: row.id.expect("There should be a tag id"), 92 | name: Arc::from(row.name), 93 | color: u32::try_from(row.color)?, 94 | }) 95 | }) 96 | .collect() 97 | } 98 | 99 | pub(crate) async fn rename(id: i64, name: &str, connection: &SqlitePool) -> Result<()> { 100 | validate_name(name, connection).await?; 101 | 102 | sqlx::query!("UPDATE tags_table SET name=$1 WHERE id=$2", name, id) 103 | .execute(connection) 104 | .await?; 105 | 106 | Ok(()) 107 | } 108 | 109 | pub(crate) async fn delete(id: i64, connection: &SqlitePool) -> Result<()> { 110 | sqlx::query!("DELETE FROM tags_table WHERE id=$1", id) 111 | .execute(connection) 112 | .await?; 113 | Ok(()) 114 | } 115 | -------------------------------------------------------------------------------- /foucault-client/src/states/tag_deletion.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use log::info; 3 | 4 | use crossterm::event::{KeyCode, KeyEvent}; 5 | use ratatui::{layout::Rect, Frame}; 6 | 7 | use crate::{ 8 | helpers::draw_yes_no_prompt, 9 | states::{ 10 | tags_managing::{draw_tags_managing_state, TagsManagingStateData}, 11 | State, 12 | }, 13 | tag::Tag, 14 | NotebookAPI, 15 | }; 16 | 17 | #[derive(Clone)] 18 | pub struct TagsDeletionStateData { 19 | tags_managing_data: TagsManagingStateData, 20 | delete: bool, 21 | } 22 | 23 | impl TagsDeletionStateData { 24 | pub fn empty(tags_managing_data: TagsManagingStateData) -> Self { 25 | TagsDeletionStateData { 26 | tags_managing_data, 27 | delete: false, 28 | } 29 | } 30 | } 31 | 32 | pub async fn run_tag_deletion_state( 33 | TagsDeletionStateData { 34 | tags_managing_data, 35 | delete, 36 | }: TagsDeletionStateData, 37 | key_event: KeyEvent, 38 | notebook: &NotebookAPI, 39 | ) -> Result { 40 | Ok(match key_event.code { 41 | KeyCode::Esc => { 42 | info!( 43 | "Cancel the deletion of tag {}.", 44 | tags_managing_data 45 | .get_selected() 46 | .expect("A tag should be selected.") 47 | .name() 48 | ); 49 | State::TagsManaging( 50 | TagsManagingStateData::from_pattern(tags_managing_data.pattern, notebook).await?, 51 | ) 52 | } 53 | KeyCode::Enter => { 54 | if delete { 55 | info!( 56 | "Delete tag {}.", 57 | tags_managing_data 58 | .get_selected() 59 | .expect("A tag should be selected.") 60 | .name() 61 | ); 62 | 63 | Tag::delete( 64 | tags_managing_data 65 | .get_selected() 66 | .expect("A tag to be selected") 67 | .id(), 68 | notebook, 69 | ) 70 | .await?; 71 | } else { 72 | info!( 73 | "Cancel the deletion of tag {}.", 74 | tags_managing_data 75 | .get_selected() 76 | .expect("A tag should be selected.") 77 | .name() 78 | ); 79 | } 80 | State::TagsManaging( 81 | TagsManagingStateData::from_pattern(tags_managing_data.pattern, notebook).await?, 82 | ) 83 | } 84 | KeyCode::Tab => State::TagDeletion(TagsDeletionStateData { 85 | tags_managing_data, 86 | delete: !delete, 87 | }), 88 | _ => State::TagDeletion(TagsDeletionStateData { 89 | tags_managing_data, 90 | delete, 91 | }), 92 | }) 93 | } 94 | 95 | pub fn draw_tag_deletion_state( 96 | TagsDeletionStateData { 97 | tags_managing_data, 98 | delete, 99 | }: &TagsDeletionStateData, 100 | notebook: &NotebookAPI, 101 | frame: &mut Frame, 102 | main_rect: Rect, 103 | ) { 104 | let selected_tag = tags_managing_data 105 | .get_selected() 106 | .expect("A tag to be selected"); 107 | 108 | draw_tags_managing_state(tags_managing_data, notebook, frame, main_rect); 109 | 110 | draw_yes_no_prompt( 111 | frame, 112 | *delete, 113 | format!("Delete tag {} ?", selected_tag.name()).as_str(), 114 | main_rect, 115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /foucault-client/src/states/tag_creation.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use log::info; 3 | 4 | use crossterm::event::{KeyCode, KeyEvent}; 5 | use ratatui::{layout::Rect, Frame}; 6 | 7 | use crate::{ 8 | helpers::{draw_text_prompt, EditableText}, 9 | states::{ 10 | tags_managing::{draw_tags_managing_state, TagsManagingStateData}, 11 | State, 12 | }, 13 | tag::Tag, 14 | NotebookAPI, 15 | }; 16 | 17 | #[derive(Clone)] 18 | pub struct TagsCreationStateData { 19 | tags_managing_data: TagsManagingStateData, 20 | name: EditableText, 21 | valid: bool, 22 | } 23 | 24 | impl TagsCreationStateData { 25 | pub fn empty(tags_managing_data: TagsManagingStateData) -> Self { 26 | TagsCreationStateData { 27 | tags_managing_data, 28 | name: EditableText::new(String::new()), 29 | valid: false, 30 | } 31 | } 32 | } 33 | 34 | pub async fn run_tag_creation_state( 35 | mut state_data: TagsCreationStateData, 36 | key_event: KeyEvent, 37 | notebook: &NotebookAPI, 38 | ) -> Result { 39 | Ok(match key_event.code { 40 | KeyCode::Esc => { 41 | info!("Cancel the tag creation."); 42 | State::TagsManaging( 43 | TagsManagingStateData::from_pattern( 44 | state_data.tags_managing_data.pattern, 45 | notebook, 46 | ) 47 | .await?, 48 | ) 49 | } 50 | KeyCode::Enter => { 51 | if Tag::validate_name(&state_data.name, notebook).await? { 52 | info!("Create tag {}.", &*state_data.name); 53 | Tag::new(state_data.name.consume(), notebook).await?; 54 | State::TagsManaging( 55 | TagsManagingStateData::from_pattern( 56 | state_data.tags_managing_data.pattern, 57 | notebook, 58 | ) 59 | .await?, 60 | ) 61 | } else { 62 | State::TagCreation(TagsCreationStateData { 63 | valid: false, 64 | ..state_data 65 | }) 66 | } 67 | } 68 | KeyCode::Backspace => { 69 | state_data.name.remove_char(); 70 | state_data.valid = Tag::validate_name(&state_data.name, notebook).await?; 71 | State::TagCreation(state_data) 72 | } 73 | KeyCode::Delete => { 74 | state_data.name.del_char(); 75 | state_data.valid = Tag::validate_name(&state_data.name, notebook).await?; 76 | State::TagCreation(state_data) 77 | } 78 | KeyCode::Left => { 79 | state_data.name.move_left(); 80 | State::TagCreation(state_data) 81 | } 82 | KeyCode::Right => { 83 | state_data.name.move_right(); 84 | State::TagCreation(state_data) 85 | } 86 | KeyCode::Char(c) if !c.is_whitespace() => { 87 | state_data.name.insert_char(c); 88 | state_data.valid = Tag::validate_name(&state_data.name, notebook).await?; 89 | State::TagCreation(state_data) 90 | } 91 | _ => State::TagCreation(state_data), 92 | }) 93 | } 94 | 95 | pub fn draw_tag_creation_state( 96 | TagsCreationStateData { 97 | tags_managing_data, 98 | name, 99 | valid, 100 | }: &TagsCreationStateData, 101 | notebook: &NotebookAPI, 102 | frame: &mut Frame, 103 | main_rect: Rect, 104 | ) { 105 | draw_tags_managing_state(tags_managing_data, notebook, frame, main_rect); 106 | draw_text_prompt(frame, "Tag name", name, *valid, main_rect); 107 | } 108 | -------------------------------------------------------------------------------- /foucault-client/src/states/note_renaming.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use log::info; 3 | 4 | use crossterm::event::{KeyCode, KeyEvent}; 5 | use ratatui::{layout::Rect, Frame}; 6 | 7 | use crate::{ 8 | helpers::{draw_text_prompt, EditableText}, 9 | note::Note, 10 | states::{ 11 | note_viewing::{draw_note_viewing_state, NoteViewingStateData}, 12 | State, 13 | }, 14 | NotebookAPI, 15 | }; 16 | 17 | #[derive(Clone)] 18 | pub struct NoteRenamingStateData { 19 | note_viewing_data: NoteViewingStateData, 20 | new_name: EditableText, 21 | valid: bool, 22 | } 23 | 24 | impl NoteRenamingStateData { 25 | pub fn empty(note_viewing_data: NoteViewingStateData) -> Self { 26 | NoteRenamingStateData { 27 | note_viewing_data, 28 | new_name: EditableText::new(String::new()), 29 | valid: false, 30 | } 31 | } 32 | } 33 | 34 | pub async fn run_note_renaming_state( 35 | mut state_data: NoteRenamingStateData, 36 | key_event: KeyEvent, 37 | notebook: &NotebookAPI, 38 | ) -> Result { 39 | Ok(match key_event.code { 40 | KeyCode::Esc => { 41 | info!( 42 | "Cancel the renaming note {}", 43 | state_data.note_viewing_data.note.name() 44 | ); 45 | State::NoteViewing( 46 | NoteViewingStateData::new(state_data.note_viewing_data.note, notebook).await?, 47 | ) 48 | } 49 | KeyCode::Enter => { 50 | if Note::validate_name(&state_data.new_name, notebook).await? { 51 | info!( 52 | "Rename note {} to {}.", 53 | state_data.note_viewing_data.note.name(), 54 | &*state_data.new_name 55 | ); 56 | state_data 57 | .note_viewing_data 58 | .note 59 | .rename(state_data.new_name.consume(), notebook) 60 | .await?; 61 | State::NoteViewing( 62 | NoteViewingStateData::new(state_data.note_viewing_data.note, notebook).await?, 63 | ) 64 | } else { 65 | State::NoteRenaming(NoteRenamingStateData { 66 | valid: false, 67 | ..state_data 68 | }) 69 | } 70 | } 71 | KeyCode::Backspace => { 72 | state_data.new_name.remove_char(); 73 | state_data.valid = Note::validate_name(&state_data.new_name, notebook).await?; 74 | State::NoteRenaming(state_data) 75 | } 76 | KeyCode::Delete => { 77 | state_data.new_name.del_char(); 78 | state_data.valid = Note::validate_name(&state_data.new_name, notebook).await?; 79 | State::NoteRenaming(state_data) 80 | } 81 | KeyCode::Left => { 82 | state_data.new_name.move_left(); 83 | State::NoteRenaming(state_data) 84 | } 85 | KeyCode::Right => { 86 | state_data.new_name.move_right(); 87 | State::NoteRenaming(state_data) 88 | } 89 | KeyCode::Char(c) => { 90 | state_data.new_name.insert_char(c); 91 | state_data.valid = Note::validate_name(&state_data.new_name, notebook).await?; 92 | State::NoteRenaming(state_data) 93 | } 94 | _ => State::NoteRenaming(state_data), 95 | }) 96 | } 97 | 98 | pub fn draw_note_renaming_state( 99 | NoteRenamingStateData { 100 | note_viewing_data, 101 | new_name, 102 | valid, 103 | }: &NoteRenamingStateData, 104 | notebook: &NotebookAPI, 105 | frame: &mut Frame, 106 | main_rect: Rect, 107 | ) { 108 | draw_note_viewing_state(note_viewing_data, notebook, frame, main_rect); 109 | draw_text_prompt(frame, "New note name", new_name, *valid, main_rect); 110 | } 111 | -------------------------------------------------------------------------------- /foucault-server/src/tag_api.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::State, http::StatusCode, Json}; 2 | 3 | use foucault_core::{ 4 | api::tag::RenameParam, 5 | pretty_error, 6 | tag_repr::{Tag, TagError}, 7 | }; 8 | 9 | use crate::{error::FailibleJsonResult, tag_queries, AppState}; 10 | 11 | pub(crate) async fn create( 12 | State(state): State, 13 | Json(name): Json, 14 | ) -> FailibleJsonResult> { 15 | if !state.permissions.writable() { 16 | return Err(StatusCode::UNAUTHORIZED); 17 | } 18 | 19 | let res = tag_queries::create(name, state.notebook.db()).await; 20 | 21 | match res { 22 | Ok(tag) => Ok((StatusCode::OK, Json::from(Ok(tag)))), 23 | Err(err) => { 24 | if let Some(tag_err) = err.downcast_ref::() { 25 | Ok((StatusCode::NOT_ACCEPTABLE, Json::from(Err(*tag_err)))) 26 | } else { 27 | pretty_error!("Error encountered during tag creation : {err}"); 28 | Err(StatusCode::INTERNAL_SERVER_ERROR) 29 | } 30 | } 31 | } 32 | } 33 | 34 | pub(crate) async fn validate_name( 35 | State(state): State, 36 | Json(name): Json, 37 | ) -> FailibleJsonResult> { 38 | let res = tag_queries::validate_name(&name, state.notebook.db()).await; 39 | 40 | match res { 41 | Ok(res) => Ok((StatusCode::OK, Json::from(res))), 42 | Err(err) => { 43 | pretty_error!("Error encountered during tag creation : {err}"); 44 | Err(StatusCode::INTERNAL_SERVER_ERROR) 45 | } 46 | } 47 | } 48 | 49 | pub(crate) async fn load_by_name( 50 | State(state): State, 51 | Json(name): Json, 52 | ) -> FailibleJsonResult> { 53 | let res = tag_queries::load_by_name(name, state.notebook.db()).await; 54 | 55 | match res { 56 | Ok(res) => Ok((StatusCode::OK, Json::from(res))), 57 | Err(err) => { 58 | pretty_error!("Error encountered while loading tag by name : {err}"); 59 | Err(StatusCode::INTERNAL_SERVER_ERROR) 60 | } 61 | } 62 | } 63 | 64 | pub(crate) async fn search_by_name( 65 | State(state): State, 66 | Json(pattern): Json, 67 | ) -> FailibleJsonResult> { 68 | let res = tag_queries::search_by_name(&pattern, state.notebook.db()).await; 69 | 70 | match res { 71 | Ok(res) => Ok((StatusCode::OK, Json::from(res))), 72 | Err(err) => { 73 | pretty_error!("Error encountered when searching for tags : {err}"); 74 | Err(StatusCode::INTERNAL_SERVER_ERROR) 75 | } 76 | } 77 | } 78 | 79 | pub(crate) async fn rename( 80 | State(state): State, 81 | Json(RenameParam { id, name }): Json, 82 | ) -> FailibleJsonResult> { 83 | if !state.permissions.writable() { 84 | return Err(StatusCode::UNAUTHORIZED); 85 | } 86 | 87 | let res = tag_queries::rename(id, &name, state.notebook.db()).await; 88 | 89 | match res { 90 | Ok(()) => Ok((StatusCode::OK, Json::from(None))), 91 | Err(err) => { 92 | if let Some(tag_err) = err.downcast_ref::() { 93 | Ok((StatusCode::NOT_ACCEPTABLE, Json::from(Some(*tag_err)))) 94 | } else { 95 | pretty_error!("Error encountered during tag renaming : {err}"); 96 | Err(StatusCode::INTERNAL_SERVER_ERROR) 97 | } 98 | } 99 | } 100 | } 101 | 102 | pub(crate) async fn delete(State(state): State, Json(id): Json) -> StatusCode { 103 | if !state.permissions.writable() { 104 | return StatusCode::UNAUTHORIZED; 105 | } 106 | 107 | let res = tag_queries::delete(id, state.notebook.db()).await; 108 | 109 | match res { 110 | Ok(()) => StatusCode::OK, 111 | Err(err) => { 112 | pretty_error!("Error encountered when deleting tag : {err}"); 113 | StatusCode::INTERNAL_SERVER_ERROR 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /foucault-client/src/states/tag_renaming.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | use crossterm::event::{KeyCode, KeyEvent}; 4 | use log::info; 5 | use ratatui::{layout::Rect, Frame}; 6 | 7 | use crate::{ 8 | helpers::{draw_text_prompt, EditableText}, 9 | states::{tags_managing::TagsManagingStateData, State}, 10 | tag::Tag, 11 | NotebookAPI, 12 | }; 13 | 14 | use super::tags_managing::draw_tags_managing_state; 15 | 16 | #[derive(Clone)] 17 | pub struct TagRenamingStateData { 18 | tags_managing_data: TagsManagingStateData, 19 | new_name: EditableText, 20 | valid: bool, 21 | } 22 | 23 | impl TagRenamingStateData { 24 | pub fn empty(tags_managing_data: TagsManagingStateData) -> Self { 25 | Self { 26 | tags_managing_data, 27 | new_name: EditableText::new(String::new()), 28 | valid: false, 29 | } 30 | } 31 | } 32 | 33 | pub async fn run_tag_renaming_state( 34 | mut state_data: TagRenamingStateData, 35 | key_event: KeyEvent, 36 | notebook: &NotebookAPI, 37 | ) -> Result { 38 | Ok(match key_event.code { 39 | KeyCode::Esc => { 40 | info!( 41 | "Cancel the renaming of tag {}", 42 | state_data 43 | .tags_managing_data 44 | .get_selected() 45 | .expect("A tag to be selected") 46 | .name() 47 | ); 48 | 49 | State::TagsManaging( 50 | TagsManagingStateData::from_pattern( 51 | state_data.tags_managing_data.pattern, 52 | notebook, 53 | ) 54 | .await?, 55 | ) 56 | } 57 | KeyCode::Enter => { 58 | if Tag::validate_name(&state_data.new_name, notebook).await? { 59 | let tag = state_data 60 | .tags_managing_data 61 | .get_selected() 62 | .expect("A tag to be selected."); 63 | 64 | info!("Rename tag {} to {}.", tag.name(), &*state_data.new_name); 65 | 66 | Tag::rename(tag.id(), state_data.new_name.consume(), notebook).await?; 67 | 68 | State::TagsManaging( 69 | TagsManagingStateData::from_pattern( 70 | state_data.tags_managing_data.pattern, 71 | notebook, 72 | ) 73 | .await?, 74 | ) 75 | } else { 76 | state_data.valid = false; 77 | State::TagRenaming(state_data) 78 | } 79 | } 80 | KeyCode::Backspace => { 81 | state_data.new_name.remove_char(); 82 | state_data.valid = Tag::validate_name(&state_data.new_name, notebook).await?; 83 | State::TagRenaming(state_data) 84 | } 85 | KeyCode::Delete => { 86 | state_data.new_name.del_char(); 87 | state_data.valid = Tag::validate_name(&state_data.new_name, notebook).await?; 88 | State::TagRenaming(state_data) 89 | } 90 | KeyCode::Left => { 91 | state_data.new_name.move_left(); 92 | State::TagRenaming(state_data) 93 | } 94 | KeyCode::Right => { 95 | state_data.new_name.move_right(); 96 | State::TagRenaming(state_data) 97 | } 98 | KeyCode::Char(c) => { 99 | state_data.new_name.insert_char(c); 100 | state_data.valid = Tag::validate_name(&state_data.new_name, notebook).await?; 101 | State::TagRenaming(state_data) 102 | } 103 | _ => State::TagRenaming(state_data), 104 | }) 105 | } 106 | 107 | pub fn draw_tag_renaming_state( 108 | TagRenamingStateData { 109 | tags_managing_data, 110 | new_name, 111 | valid, 112 | }: &TagRenamingStateData, 113 | notebook: &NotebookAPI, 114 | frame: &mut Frame, 115 | main_rect: Rect, 116 | ) { 117 | draw_tags_managing_state(tags_managing_data, notebook, frame, main_rect); 118 | draw_text_prompt(frame, "New tag name", new_name, *valid, main_rect); 119 | } 120 | -------------------------------------------------------------------------------- /foucault-client/src/states/note_creation.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use log::info; 3 | 4 | use crossterm::event::{KeyCode, KeyEvent}; 5 | use ratatui::{layout::Rect, Frame}; 6 | 7 | use crate::{ 8 | helpers::{draw_text_prompt, EditableText}, 9 | note::Note, 10 | states::{ 11 | note_viewing::NoteViewingStateData, 12 | notes_managing::{draw_notes_managing_state, NotesManagingStateData}, 13 | State, 14 | }, 15 | NotebookAPI, 16 | }; 17 | 18 | #[derive(Clone)] 19 | enum PrecidingState { 20 | Nothing, 21 | NotesManaging(NotesManagingStateData), 22 | } 23 | 24 | #[derive(Clone)] 25 | pub struct NoteCreationStateData { 26 | preciding_state: PrecidingState, 27 | name: EditableText, 28 | valid: bool, 29 | } 30 | 31 | impl NoteCreationStateData { 32 | pub fn from_nothing() -> Self { 33 | NoteCreationStateData { 34 | preciding_state: PrecidingState::Nothing, 35 | name: EditableText::new(String::new()), 36 | valid: false, 37 | } 38 | } 39 | 40 | pub fn from_notes_managing(state_data: NotesManagingStateData) -> Self { 41 | NoteCreationStateData { 42 | preciding_state: PrecidingState::NotesManaging(state_data), 43 | name: EditableText::new(String::new()), 44 | valid: false, 45 | } 46 | } 47 | } 48 | 49 | pub async fn run_note_creation_state( 50 | mut state_data: NoteCreationStateData, 51 | key_event: KeyEvent, 52 | notebook: &NotebookAPI, 53 | ) -> Result { 54 | Ok(match key_event.code { 55 | KeyCode::Esc => { 56 | info!("Cancel the note creation."); 57 | match state_data.preciding_state { 58 | PrecidingState::Nothing => State::Nothing, 59 | PrecidingState::NotesManaging(state) => State::NotesManaging( 60 | NotesManagingStateData::from_pattern(state.pattern, notebook).await?, 61 | ), 62 | } 63 | } 64 | KeyCode::Enter => { 65 | if Note::validate_name(&state_data.name, notebook).await? { 66 | info!("Create note : {}.", &*state_data.name); 67 | 68 | let new_note = 69 | Note::new(state_data.name.consume(), String::new(), notebook).await?; 70 | 71 | State::NoteViewing(NoteViewingStateData::new(new_note, notebook).await?) 72 | } else { 73 | State::NoteCreation(NoteCreationStateData { 74 | valid: false, 75 | ..state_data 76 | }) 77 | } 78 | } 79 | KeyCode::Backspace => { 80 | state_data.name.remove_char(); 81 | state_data.valid = Note::validate_name(&state_data.name, notebook).await?; 82 | State::NoteCreation(state_data) 83 | } 84 | KeyCode::Delete => { 85 | state_data.name.del_char(); 86 | state_data.valid = Note::validate_name(&state_data.name, notebook).await?; 87 | State::NoteCreation(state_data) 88 | } 89 | KeyCode::Left => { 90 | state_data.name.move_left(); 91 | State::NoteCreation(state_data) 92 | } 93 | KeyCode::Right => { 94 | state_data.name.move_right(); 95 | State::NoteCreation(state_data) 96 | } 97 | KeyCode::Char(c) => { 98 | state_data.name.insert_char(c); 99 | state_data.valid = Note::validate_name(&state_data.name, notebook).await?; 100 | State::NoteCreation(state_data) 101 | } 102 | _ => State::NoteCreation(state_data), 103 | }) 104 | } 105 | 106 | pub fn draw_note_creation_state( 107 | NoteCreationStateData { 108 | preciding_state, 109 | name, 110 | valid, 111 | }: &NoteCreationStateData, 112 | notebook: &NotebookAPI, 113 | frame: &mut Frame, 114 | main_rect: Rect, 115 | ) { 116 | match preciding_state { 117 | PrecidingState::Nothing => {} 118 | PrecidingState::NotesManaging(state) => { 119 | draw_notes_managing_state(state, notebook, frame, main_rect); 120 | } 121 | } 122 | 123 | draw_text_prompt(frame, "Note name", name, *valid, main_rect); 124 | } 125 | -------------------------------------------------------------------------------- /foucault-server/src/notebook.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use anyhow::Result; 7 | use foucault_core::pretty_error; 8 | use thiserror::Error; 9 | 10 | use tokio::fs; 11 | 12 | use sqlx::{sqlite::SqlitePoolOptions, SqlitePool}; 13 | 14 | pub struct Notebook { 15 | pub name: String, 16 | file: PathBuf, 17 | db_pool: SqlitePool, 18 | } 19 | 20 | #[derive(Error, Debug)] 21 | pub enum OpeningError { 22 | #[error("No notebook named {name} was found.")] 23 | NotebookNotFound { name: String }, 24 | } 25 | 26 | #[derive(Error, Debug)] 27 | pub enum CreationError { 28 | #[error("Another notebook named {name} was found.")] 29 | NotebookAlreadyExists { name: String }, 30 | } 31 | 32 | #[derive(Error, Debug)] 33 | pub enum SuppressionError { 34 | #[error("No notebook named {name} was found.")] 35 | NoNotebookExists { name: String }, 36 | } 37 | 38 | impl Notebook { 39 | #[must_use] 40 | pub fn db(&self) -> &SqlitePool { 41 | &self.db_pool 42 | } 43 | 44 | #[must_use] 45 | pub fn dir(&self) -> Option<&Path> { 46 | self.file.parent() 47 | } 48 | 49 | pub async fn open_notebook(name: &str, dir: &Path) -> Result { 50 | let notebook_path = { 51 | let app_dir_notebook_path = dir.join(format!("{name}.book")); 52 | let current_dir_notebook_path = env::current_dir()?.join(format!("{name}.book")); 53 | 54 | if app_dir_notebook_path.exists() { 55 | app_dir_notebook_path 56 | } else if current_dir_notebook_path.exists() { 57 | current_dir_notebook_path 58 | } else { 59 | pretty_error!("The notebook \"{name}\" was not found."); 60 | return Err(OpeningError::NotebookNotFound { 61 | name: name.to_owned(), 62 | } 63 | .into()); 64 | } 65 | }; 66 | 67 | let database = SqlitePoolOptions::new() 68 | .connect(&format!( 69 | "sqlite://{}", 70 | notebook_path 71 | .to_str() 72 | .expect("The notebook path must be valid unicode") 73 | )) 74 | .await 75 | .unwrap_or_else(|_| { 76 | pretty_error!("Unable to open the notebook \"{name}\"."); 77 | todo!(); 78 | }); 79 | 80 | Ok(Notebook { 81 | name: name.to_owned(), 82 | file: notebook_path, 83 | db_pool: database, 84 | }) 85 | } 86 | 87 | pub async fn new_notebook(name: &str, dir: &Path) -> Result { 88 | let notebook_path = dir.join(format!("{name}.book")); 89 | 90 | if notebook_path.try_exists()? { 91 | pretty_error!("A notebook named \"{name}\" already exists."); 92 | return Err(CreationError::NotebookAlreadyExists { 93 | name: name.to_owned(), 94 | } 95 | .into()); 96 | } 97 | 98 | let database = SqlitePoolOptions::new() 99 | .connect(&format!( 100 | "sqlite://{}?mode=rwc", 101 | notebook_path 102 | .to_str() 103 | .expect("The notebook path must be valid unicode") 104 | )) 105 | .await 106 | .unwrap_or_else(|_| { 107 | pretty_error!("Unable to open the notebook \"{name}\"."); 108 | todo!(); 109 | }); 110 | 111 | // Initialize 112 | sqlx::migrate!().run(&database).await?; 113 | 114 | Ok(Notebook { 115 | name: name.to_string(), 116 | file: notebook_path, 117 | db_pool: database, 118 | }) 119 | } 120 | 121 | pub async fn delete_notebook(name: &str, dir: &Path) -> Result<()> { 122 | let notebook_path = dir.join(format!("{name}.book")); 123 | 124 | if !notebook_path.exists() { 125 | pretty_error!("No notebook named {name} exists."); 126 | return Err(SuppressionError::NoNotebookExists { 127 | name: name.to_owned(), 128 | } 129 | .into()); 130 | } 131 | 132 | fs::remove_file(notebook_path).await?; 133 | Ok(()) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /foucault-client/src/tag.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | use foucault_core::{ 4 | api, 5 | tag_repr::{self, TagError}, 6 | }; 7 | 8 | use crate::{response_error::TryResponseCode, ApiError, NotebookAPI}; 9 | 10 | #[derive(Debug, Clone)] 11 | pub struct Tag { 12 | inner: tag_repr::Tag, 13 | } 14 | 15 | impl From for Tag { 16 | fn from(inner: tag_repr::Tag) -> Self { 17 | Self { inner } 18 | } 19 | } 20 | 21 | impl Tag { 22 | pub async fn new(name: String, notebook: &NotebookAPI) -> Result { 23 | let res = notebook 24 | .client 25 | .post(notebook.build_url("/tag/create")) 26 | .json(&name) 27 | .send() 28 | .await 29 | .map_err(ApiError::UnableToContactRemoteNotebook)? 30 | .try_response_code()? 31 | .json::>() 32 | .await 33 | .map_err(ApiError::UnableToParseResponse)?; 34 | 35 | match res { 36 | Ok(tag) => Ok(Self::from(tag)), 37 | Err(err) => { 38 | panic!("The tag name was invalid : {err}"); 39 | } 40 | } 41 | } 42 | 43 | pub async fn validate_name(name: &str, notebook: &NotebookAPI) -> Result { 44 | let res = notebook 45 | .client 46 | .get(notebook.build_url("/tag/validate/name")) 47 | .json(name) 48 | .send() 49 | .await 50 | .map_err(ApiError::UnableToContactRemoteNotebook)? 51 | .try_response_code()? 52 | .json::>() 53 | .await 54 | .map_err(ApiError::UnableToParseResponse)?; 55 | 56 | Ok(res.is_none()) 57 | } 58 | 59 | pub async fn load_by_name(name: &str, notebook: &NotebookAPI) -> Result> { 60 | let res = notebook 61 | .client 62 | .get(notebook.build_url("/tag/load/name")) 63 | .json(name) 64 | .send() 65 | .await 66 | .map_err(ApiError::UnableToContactRemoteNotebook)? 67 | .try_response_code()? 68 | .json::>() 69 | .await 70 | .map_err(ApiError::UnableToParseResponse)?; 71 | 72 | Ok(res.map(Self::from)) 73 | } 74 | 75 | pub async fn search_by_name(pattern: &str, notebook: &NotebookAPI) -> Result> { 76 | let res = notebook 77 | .client 78 | .get(notebook.build_url("/tag/search/name")) 79 | .json(pattern) 80 | .send() 81 | .await 82 | .map_err(ApiError::UnableToContactRemoteNotebook)? 83 | .try_response_code()? 84 | .json::>() 85 | .await 86 | .map_err(ApiError::UnableToParseResponse)?; 87 | 88 | Ok(res.into_iter().map(Self::from).collect()) 89 | } 90 | 91 | pub fn id(&self) -> i64 { 92 | self.inner.id 93 | } 94 | pub fn name(&self) -> &str { 95 | &self.inner.name 96 | } 97 | pub fn color(&self) -> u32 { 98 | self.inner.color 99 | } 100 | 101 | pub async fn rename(id: i64, name: String, notebook: &NotebookAPI) -> Result<()> { 102 | let res = notebook 103 | .client 104 | .patch(notebook.build_url("/tag/update/name")) 105 | .json(&api::tag::RenameParam { 106 | id, 107 | name: name.clone(), 108 | }) 109 | .send() 110 | .await 111 | .map_err(ApiError::UnableToContactRemoteNotebook)? 112 | .try_response_code()? 113 | .json::>() 114 | .await 115 | .map_err(ApiError::UnableToParseResponse)?; 116 | 117 | if let Some(err) = res { 118 | panic!("The tag name is invalid : {err}"); 119 | } 120 | 121 | Ok(()) 122 | } 123 | 124 | pub async fn delete(id: i64, notebook: &NotebookAPI) -> Result<()> { 125 | notebook 126 | .client 127 | .delete(notebook.build_url("/tag/delete")) 128 | .json(&id) 129 | .send() 130 | .await 131 | .map_err(ApiError::UnableToContactRemoteNotebook)? 132 | .try_response_code()?; 133 | 134 | Ok(()) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /foucault-client/src/states/nothing.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use log::info; 3 | 4 | use crossterm::event::{KeyCode, KeyEvent}; 5 | use ratatui::{ 6 | layout::{Constraint, Direction, Layout, Rect}, 7 | prelude::Alignment, 8 | style::{Color, Modifier, Style, Styled}, 9 | text::{Line, Span}, 10 | widgets::{Block, BorderType, Borders, Cell, Padding, Paragraph, Row, Table}, 11 | Frame, 12 | }; 13 | 14 | use foucault_core::permissions::Permissions; 15 | 16 | use crate::{ 17 | helpers::{create_popup, Capitalize}, 18 | states::{ 19 | note_creation::NoteCreationStateData, notes_managing::NotesManagingStateData, 20 | tags_managing::TagsManagingStateData, State, 21 | }, 22 | NotebookAPI, 23 | }; 24 | 25 | pub async fn run_nothing_state(key_event: KeyEvent, notebook: &NotebookAPI) -> Result { 26 | Ok(match key_event.code { 27 | KeyCode::Esc | KeyCode::Char('q') => { 28 | info!("Quit foucault."); 29 | State::Exit 30 | } 31 | KeyCode::Char('c') if notebook.permissions.writable() => { 32 | info!("Open the note creation prompt."); 33 | State::NoteCreation(NoteCreationStateData::from_nothing()) 34 | } 35 | KeyCode::Char('s') => { 36 | info!("Open the notes manager."); 37 | State::NotesManaging(NotesManagingStateData::empty(notebook).await?) 38 | } 39 | KeyCode::Char('t') => { 40 | info!("Open the tags manager."); 41 | State::TagsManaging(TagsManagingStateData::empty(notebook).await?) 42 | } 43 | _ => State::Nothing, 44 | }) 45 | } 46 | 47 | pub fn draw_nothing_state(notebook: &NotebookAPI, frame: &mut Frame, main_rect: Rect) { 48 | let mut tile_text = vec![Line::from(vec![Span::raw(notebook.name.capitalize()) 49 | .style( 50 | Style::new() 51 | .fg(Color::Blue) 52 | .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), 53 | )])]; 54 | 55 | if matches!(notebook.permissions, Permissions::ReadOnly) { 56 | tile_text.push(Line::from(vec![Span::raw(" READ ONLY ").style( 57 | Style::new() 58 | .fg(Color::White) 59 | .bg(Color::Red) 60 | .add_modifier(Modifier::BOLD), 61 | )])); 62 | } 63 | 64 | let title = Paragraph::new(tile_text).alignment(Alignment::Center); 65 | 66 | let writable_op_color = if notebook.permissions.writable() { 67 | Color::Blue 68 | } else { 69 | Color::Red 70 | }; 71 | let commands = Table::new( 72 | [ 73 | Row::new([ 74 | Cell::from("q").set_style(Style::new().fg(Color::White).bg(Color::LightBlue)), 75 | Cell::from("Quit Foucault") 76 | .set_style(Style::new().fg(Color::White).bg(Color::Black)), 77 | ]), 78 | Row::new([ 79 | Cell::from("c").set_style(Style::new().fg(Color::White).bg(writable_op_color)), 80 | Cell::from("Create new note") 81 | .set_style(Style::new().fg(Color::White).bg(Color::Black)), 82 | ]), 83 | Row::new([ 84 | Cell::from("s").set_style(Style::new().fg(Color::White).bg(Color::LightBlue)), 85 | Cell::from("Search through notes") 86 | .set_style(Style::new().fg(Color::White).bg(Color::Black)), 87 | ]), 88 | Row::new([ 89 | Cell::from("t").set_style(Style::new().fg(Color::White).bg(Color::LightBlue)), 90 | Cell::from("Manage tags").set_style(Style::new().fg(Color::White).bg(Color::Black)), 91 | ]), 92 | ], 93 | [Constraint::Length(3), Constraint::Fill(1)], 94 | ) 95 | .block( 96 | Block::new() 97 | .padding(Padding::horizontal(1)) 98 | .borders(Borders::all()) 99 | .border_type(BorderType::Double) 100 | .border_style(Style::new().fg(Color::White)), 101 | ); 102 | 103 | let title_layout = Layout::new( 104 | Direction::Vertical, 105 | [Constraint::Length(2), Constraint::Fill(1)], 106 | ) 107 | .split(create_popup( 108 | (Constraint::Percentage(40), Constraint::Length(8)), 109 | main_rect, 110 | )); 111 | 112 | frame.render_widget(title, title_layout[0]); 113 | frame.render_widget(commands, title_layout[1]); 114 | } 115 | -------------------------------------------------------------------------------- /foucault-client/src/states/note_tag_addition.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use log::info; 3 | 4 | use crossterm::event::{KeyCode, KeyEvent}; 5 | use ratatui::{layout::Rect, Frame}; 6 | 7 | use crate::{ 8 | helpers::{draw_text_prompt, EditableText}, 9 | states::{ 10 | note_tags_managing::{draw_note_tags_managing_state, NoteTagsManagingStateData}, 11 | State, 12 | }, 13 | tag::Tag, 14 | NotebookAPI, 15 | }; 16 | 17 | #[derive(Clone)] 18 | pub struct NoteTagAdditionStateData { 19 | note_tags_managing_data: NoteTagsManagingStateData, 20 | tag_name: EditableText, 21 | valid: bool, 22 | } 23 | 24 | impl NoteTagAdditionStateData { 25 | pub fn empty(note_tags_managing_data: NoteTagsManagingStateData) -> Self { 26 | NoteTagAdditionStateData { 27 | note_tags_managing_data, 28 | tag_name: EditableText::new(String::new()), 29 | valid: false, 30 | } 31 | } 32 | 33 | async fn validate(&mut self, notebook: &NotebookAPI) -> Result<()> { 34 | // TODO : Better error handling possible here 35 | self.valid = if let Some(tag) = Tag::load_by_name(&self.tag_name, notebook).await? { 36 | self.note_tags_managing_data 37 | .note 38 | .validate_tag(tag.id(), notebook) 39 | .await? 40 | } else { 41 | false 42 | }; 43 | Ok(()) 44 | } 45 | } 46 | 47 | pub async fn run_note_tag_addition_state( 48 | mut state_data: NoteTagAdditionStateData, 49 | key_event: KeyEvent, 50 | notebook: &NotebookAPI, 51 | ) -> Result { 52 | Ok(match key_event.code { 53 | KeyCode::Esc => { 54 | info!( 55 | "Cancel the tag addition to note {}.", 56 | state_data.note_tags_managing_data.note.name() 57 | ); 58 | 59 | State::NoteTagsManaging( 60 | NoteTagsManagingStateData::new(state_data.note_tags_managing_data.note, notebook) 61 | .await?, 62 | ) 63 | } 64 | KeyCode::Enter => match Tag::load_by_name(&state_data.tag_name, notebook).await? { 65 | Some(tag) 66 | if state_data 67 | .note_tags_managing_data 68 | .note 69 | .validate_tag(tag.id(), notebook) 70 | .await? => 71 | { 72 | info!( 73 | "Add tag {} to note {}.", 74 | tag.name(), 75 | state_data.note_tags_managing_data.note.name() 76 | ); 77 | state_data 78 | .note_tags_managing_data 79 | .note 80 | .add_tag(tag.id(), notebook) 81 | .await?; 82 | 83 | State::NoteTagsManaging( 84 | NoteTagsManagingStateData::new( 85 | state_data.note_tags_managing_data.note, 86 | notebook, 87 | ) 88 | .await?, 89 | ) 90 | } 91 | _ => { 92 | state_data.valid = false; 93 | 94 | State::NoteTagAddition(state_data) 95 | } 96 | }, 97 | KeyCode::Backspace => { 98 | state_data.tag_name.remove_char(); 99 | state_data.validate(notebook).await?; 100 | 101 | State::NoteTagAddition(state_data) 102 | } 103 | KeyCode::Delete => { 104 | state_data.tag_name.del_char(); 105 | state_data.validate(notebook).await?; 106 | 107 | State::NoteTagAddition(state_data) 108 | } 109 | KeyCode::Left => { 110 | state_data.tag_name.move_left(); 111 | 112 | State::NoteTagAddition(state_data) 113 | } 114 | KeyCode::Right => { 115 | state_data.tag_name.move_right(); 116 | 117 | State::NoteTagAddition(state_data) 118 | } 119 | KeyCode::Char(c) if !c.is_whitespace() => { 120 | state_data.tag_name.insert_char(c); 121 | state_data.validate(notebook).await?; 122 | 123 | State::NoteTagAddition(state_data) 124 | } 125 | _ => State::NoteTagAddition(state_data), 126 | }) 127 | } 128 | 129 | pub fn draw_note_tag_addition_state( 130 | NoteTagAdditionStateData { 131 | note_tags_managing_data, 132 | tag_name, 133 | valid, 134 | }: &NoteTagAdditionStateData, 135 | notebook: &NotebookAPI, 136 | frame: &mut Frame, 137 | main_rect: Rect, 138 | ) { 139 | draw_note_tags_managing_state(note_tags_managing_data, notebook, frame, main_rect); 140 | draw_text_prompt(frame, "Tag name", tag_name, *valid, main_rect); 141 | } 142 | -------------------------------------------------------------------------------- /foucault-client/src/states/note_deletion.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use log::info; 3 | 4 | use crossterm::event::{KeyCode, KeyEvent}; 5 | use ratatui::{layout::Rect, Frame}; 6 | 7 | use crate::{ 8 | helpers::draw_yes_no_prompt, 9 | note::Note, 10 | states::{ 11 | note_viewing::{draw_note_viewing_state, NoteViewingStateData}, 12 | notes_managing::{draw_notes_managing_state, NotesManagingStateData}, 13 | State, 14 | }, 15 | NotebookAPI, 16 | }; 17 | 18 | use foucault_core::note_repr::NoteError; 19 | 20 | #[derive(Clone)] 21 | enum PrecidingState { 22 | NoteViewingState(NoteViewingStateData), 23 | NotesManagingState(NotesManagingStateData), 24 | } 25 | 26 | #[derive(Clone)] 27 | pub struct NoteDeletionStateData { 28 | preciding_state: PrecidingState, 29 | note_name: String, 30 | note_id: i64, 31 | delete: bool, 32 | } 33 | 34 | impl NoteDeletionStateData { 35 | pub fn from_note_viewing(state: NoteViewingStateData) -> Self { 36 | NoteDeletionStateData { 37 | note_name: state.note.name().to_string(), 38 | note_id: state.note.id(), 39 | delete: false, 40 | preciding_state: PrecidingState::NoteViewingState(state), 41 | } 42 | } 43 | pub fn from_notes_managing( 44 | note_name: String, 45 | note_id: i64, 46 | state: NotesManagingStateData, 47 | ) -> Self { 48 | NoteDeletionStateData { 49 | note_name, 50 | note_id, 51 | delete: false, 52 | preciding_state: PrecidingState::NotesManagingState(state), 53 | } 54 | } 55 | } 56 | 57 | pub async fn run_note_deletion_state( 58 | state_data: NoteDeletionStateData, 59 | key_event: KeyEvent, 60 | notebook: &NotebookAPI, 61 | ) -> Result { 62 | Ok(match key_event.code { 63 | KeyCode::Esc => { 64 | info!("Cancel the deletion of note {}.", &state_data.note_name); 65 | match state_data.preciding_state { 66 | PrecidingState::NoteViewingState(_) => State::NoteViewing( 67 | NoteViewingStateData::new( 68 | Note::load_by_id(state_data.note_id, notebook) 69 | .await? 70 | .ok_or(NoteError::DoesNotExist)?, 71 | notebook, 72 | ) 73 | .await?, 74 | ), 75 | PrecidingState::NotesManagingState(state) => State::NotesManaging( 76 | NotesManagingStateData::from_pattern(state.pattern, notebook).await?, 77 | ), 78 | } 79 | } 80 | KeyCode::Tab => State::NoteDeletion(NoteDeletionStateData { 81 | delete: !state_data.delete, 82 | ..state_data 83 | }), 84 | KeyCode::Enter => { 85 | if state_data.delete { 86 | info!("Delete note {}.", &state_data.note_name); 87 | Note::delete(state_data.note_id, notebook).await?; 88 | match state_data.preciding_state { 89 | PrecidingState::NoteViewingState(_) => State::Nothing, 90 | PrecidingState::NotesManagingState(state) => State::NotesManaging( 91 | NotesManagingStateData::from_pattern(state.pattern, notebook).await?, 92 | ), 93 | } 94 | } else { 95 | info!("Cancel the deletion of note {}.", &state_data.note_name); 96 | match state_data.preciding_state { 97 | PrecidingState::NoteViewingState(_) => State::NoteViewing( 98 | NoteViewingStateData::new( 99 | Note::load_by_id(state_data.note_id, notebook) 100 | .await? 101 | .ok_or(NoteError::DoesNotExist)?, 102 | notebook, 103 | ) 104 | .await?, 105 | ), 106 | PrecidingState::NotesManagingState(state) => State::NotesManaging( 107 | NotesManagingStateData::from_pattern(state.pattern, notebook).await?, 108 | ), 109 | } 110 | } 111 | } 112 | _ => State::NoteDeletion(state_data), 113 | }) 114 | } 115 | 116 | pub fn draw_note_deletion_state( 117 | NoteDeletionStateData { 118 | preciding_state, 119 | delete, 120 | .. 121 | }: &NoteDeletionStateData, 122 | notebook: &NotebookAPI, 123 | frame: &mut Frame, 124 | main_rect: Rect, 125 | ) { 126 | match preciding_state { 127 | PrecidingState::NoteViewingState(state) => { 128 | draw_note_viewing_state(state, notebook, frame, main_rect); 129 | } 130 | PrecidingState::NotesManagingState(state) => { 131 | draw_notes_managing_state(state, notebook, frame, main_rect); 132 | } 133 | } 134 | draw_yes_no_prompt(frame, *delete, "Delete note ?", main_rect); 135 | } 136 | -------------------------------------------------------------------------------- /src/notebook_selector.rs: -------------------------------------------------------------------------------- 1 | use std::{env, ffi::OsString, fs, io::stdout, path::Path, time::Duration}; 2 | 3 | use anyhow::Result; 4 | use log::info; 5 | use scopeguard::defer; 6 | use thiserror::Error; 7 | 8 | use crossterm::{ 9 | event::{self, Event, KeyCode, KeyEventKind}, 10 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 11 | ExecutableCommand, 12 | }; 13 | use ratatui::{ 14 | prelude::{Alignment, CrosstermBackend, Margin}, 15 | style::{Color, Modifier, Style}, 16 | text::Text, 17 | widgets::{ 18 | Block, BorderType, Borders, List, ListDirection, ListState, Padding, Scrollbar, 19 | ScrollbarOrientation, ScrollbarState, 20 | }, 21 | Terminal, 22 | }; 23 | 24 | #[derive(Clone, Debug, Error)] 25 | pub enum NotebookSelectorError { 26 | #[error("The notebook name couldn't be decoded : {name:?}")] 27 | InvalidNotebookName { name: OsString }, 28 | } 29 | 30 | pub fn open_selector(dir: &Path) -> Result> { 31 | info!("Open notebook selector."); 32 | 33 | // Retreive notebooks 34 | 35 | let notebooks = fs::read_dir(dir)? 36 | .chain(fs::read_dir(env::current_dir()?)?) 37 | .filter_map(|file| { 38 | file.map_err(anyhow::Error::from) 39 | .map(|file| { 40 | let file_path = file.path(); 41 | match file_path.extension() { 42 | Some(extension) if extension == "book" => Some(file_path), 43 | _ => None, 44 | } 45 | }) 46 | .transpose() 47 | }) 48 | .map(|file_path| { 49 | file_path.and_then(|file_path| { 50 | file_path 51 | .file_stem() 52 | .ok_or_else(|| { 53 | NotebookSelectorError::InvalidNotebookName { 54 | name: file_path.file_name().unwrap().to_os_string(), 55 | } 56 | .into() 57 | }) 58 | .and_then(|stem| { 59 | stem.to_os_string().into_string().map_err(|e| { 60 | NotebookSelectorError::InvalidNotebookName { name: e.clone() }.into() 61 | }) 62 | }) 63 | }) 64 | }) 65 | .collect::>>()?; 66 | 67 | // Display 68 | enable_raw_mode().expect("Prepare terminal"); 69 | stdout() 70 | .execute(EnterAlternateScreen) 71 | .expect("Prepare terminal"); 72 | 73 | defer! { 74 | stdout().execute(LeaveAlternateScreen).expect("Reset terminal"); 75 | disable_raw_mode().expect("Reset terminal"); 76 | } 77 | 78 | let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; 79 | 80 | let mut selected = 0; 81 | 82 | loop { 83 | if event::poll(Duration::from_millis(50))? { 84 | if let Event::Key(key) = event::read()? { 85 | if key.kind == KeyEventKind::Press { 86 | match key.code { 87 | KeyCode::Esc | KeyCode::Char('q') => { 88 | info!("Quit notebook selector."); 89 | break Ok(None); 90 | } 91 | KeyCode::Up | KeyCode::Char('k') if selected > 0 => selected -= 1, 92 | KeyCode::Down | KeyCode::Char('j') 93 | if selected < notebooks.len().saturating_sub(1) => 94 | { 95 | selected += 1; 96 | } 97 | KeyCode::Enter => { 98 | break Ok(Some(notebooks[selected].clone())); 99 | } 100 | _ => {} 101 | } 102 | } 103 | } 104 | } 105 | 106 | // Draw 107 | terminal.draw(|frame| { 108 | let main_block = Block::new() 109 | .title("Foucault") 110 | .title_alignment(Alignment::Center) 111 | .title_style(Style::new().add_modifier(Modifier::BOLD)) 112 | .padding(Padding::new(2, 2, 1, 1)) 113 | .borders(Borders::all()) 114 | .border_style(Style::new().fg(Color::White)) 115 | .border_type(BorderType::Rounded); 116 | 117 | let list = List::default() 118 | .items( 119 | notebooks 120 | .iter() 121 | .map(|notebook| Text::styled(notebook, Style::new())), 122 | ) 123 | .highlight_symbol(">>") 124 | .highlight_style(Style::new().fg(Color::Black).bg(Color::White)) 125 | .direction(ListDirection::TopToBottom); 126 | 127 | let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) 128 | .begin_symbol(Some("↑")) 129 | .end_symbol(Some("↓")); 130 | 131 | frame.render_stateful_widget( 132 | list, 133 | main_block.inner(frame.area()), 134 | &mut ListState::default().with_selected(Some(selected)), 135 | ); 136 | frame.render_widget(main_block, frame.area()); 137 | frame.render_stateful_widget( 138 | scrollbar, 139 | frame.area().inner(Margin::new(0, 1)), 140 | &mut ScrollbarState::new(notebooks.len()).position(selected), 141 | ); 142 | })?; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /foucault-client/src/markdown.rs: -------------------------------------------------------------------------------- 1 | pub mod elements; 2 | 3 | use markdown::{to_mdast, ParseOptions}; 4 | 5 | use ratatui::{ 6 | prelude::Alignment, 7 | style::{Color, Modifier}, 8 | text::Span, 9 | }; 10 | 11 | use crate::markdown::elements::{ 12 | BlockElement, BlockElements, InlineElement, RenderedBlock, SelectableInlineElements, 13 | HEADING_STYLE, 14 | }; 15 | 16 | const HEADER_COLOR: [Color; 6] = [ 17 | Color::Red, 18 | Color::Green, 19 | Color::Blue, 20 | Color::Yellow, 21 | Color::Magenta, 22 | Color::Cyan, 23 | ]; 24 | const HEADER_MODIFIER: [Modifier; 6] = [ 25 | Modifier::BOLD, 26 | Modifier::empty(), 27 | Modifier::ITALIC, 28 | Modifier::empty(), 29 | Modifier::DIM, 30 | Modifier::DIM, 31 | ]; 32 | const HEADER_ALIGNEMENT: [Alignment; 6] = [ 33 | Alignment::Center, 34 | Alignment::Center, 35 | Alignment::Left, 36 | Alignment::Left, 37 | Alignment::Left, 38 | Alignment::Left, 39 | ]; 40 | 41 | const BLOCKQUOTE_ALIGNEMENT: Alignment = Alignment::Center; 42 | 43 | const TEXT: usize = 0; 44 | const ITALIC: usize = 1; 45 | const STRONG: usize = 2; 46 | const HYPERLINK: usize = 3; 47 | const CROSS_REF: usize = 4; 48 | const BLOCKQUOTE: usize = 5; 49 | 50 | const RICH_TEXT_COLOR: [Color; 6] = [ 51 | Color::Reset, // Text 52 | Color::Green, // Italic 53 | Color::Yellow, // Strong 54 | Color::LightBlue, // Link 55 | Color::Cyan, // Cross ref 56 | Color::Yellow, // Blockquote 57 | ]; 58 | 59 | #[derive(Debug)] 60 | pub struct Header { 61 | pub text: String, 62 | level: u8, 63 | } 64 | 65 | impl Header { 66 | pub fn build_span(&self) -> Span<'_> { 67 | Span::raw(&self.text).style(HEADING_STYLE[usize::from(self.level)]) 68 | } 69 | } 70 | 71 | pub struct ParsedMarkdown { 72 | parsed_content: Vec>, 73 | } 74 | 75 | impl ParsedMarkdown { 76 | pub fn get_element(&self, el: (usize, usize)) -> Option<&SelectableInlineElements> { 77 | if let Some(block) = &self.parsed_content.get(el.1) { 78 | block.get_content().get(el.0) 79 | } else { 80 | None 81 | } 82 | } 83 | 84 | pub fn select(&mut self, el: (usize, usize), selected: bool) { 85 | if let Some(block) = self.parsed_content.get_mut(el.1) { 86 | if let Some(element) = block.get_content_mut().get_mut(el.0) { 87 | element.select(selected); 88 | } 89 | } 90 | } 91 | 92 | pub fn list_links(&self) -> Vec<&str> { 93 | self.parsed_content 94 | .iter() 95 | .flat_map(|block| block.get_content().iter()) 96 | .map(|el| &el.element) 97 | .filter_map(|el| el.link_dest()) 98 | .collect() 99 | } 100 | 101 | pub fn related_header(&self, block: usize) -> Option { 102 | if self.parsed_content.is_empty() { 103 | return None; 104 | } 105 | self.parsed_content[0..=block] 106 | .iter() 107 | .fold(None, |acc, el| match el { 108 | BlockElements::Heading { .. } => Some(match acc { 109 | Some(v) => v + 1, 110 | None => 0, 111 | }), 112 | _ => acc, 113 | }) 114 | } 115 | 116 | pub fn header_index(&self, header: usize) -> Option { 117 | let mut header_counter = 0; 118 | self.parsed_content 119 | .iter() 120 | .enumerate() 121 | .find_map(move |(block, el)| match el { 122 | BlockElements::Heading { .. } => { 123 | if header_counter == header { 124 | Some(block) 125 | } else { 126 | header_counter += 1; 127 | None 128 | } 129 | } 130 | _ => None, 131 | }) 132 | } 133 | 134 | pub fn list_headers(&self) -> Vec
{ 135 | self.parsed_content 136 | .iter() 137 | .filter_map(|el| match el { 138 | BlockElements::Heading { content, level } => { 139 | let text = content.iter().map(InlineElement::inner_text).collect(); 140 | Some(Header { 141 | text, 142 | level: *level, 143 | }) 144 | } 145 | _ => None, 146 | }) 147 | .collect() 148 | } 149 | 150 | pub fn render_blocks(&self, max_len: usize) -> Vec { 151 | self.parsed_content 152 | .iter() 153 | .map(BlockElement::render_lines) 154 | .map(|block| block.wrap_lines(max_len)) 155 | .collect() 156 | } 157 | 158 | pub fn block_count(&self) -> usize { 159 | self.parsed_content.len() 160 | } 161 | 162 | pub fn block_length(&self, block: usize) -> usize { 163 | self.parsed_content.get(block).map_or(0, BlockElement::len) 164 | } 165 | } 166 | 167 | pub fn parse(content: &str) -> ParsedMarkdown { 168 | ParsedMarkdown { 169 | parsed_content: BlockElements::parse_node( 170 | &to_mdast(content, &ParseOptions::default()).unwrap(), 171 | ), 172 | } 173 | } 174 | 175 | pub fn lines(blocks: &[RenderedBlock]) -> usize { 176 | blocks.iter().map(RenderedBlock::line_count).sum() 177 | } 178 | 179 | pub fn combine(blocks: &[RenderedBlock]) -> RenderedBlock { 180 | blocks 181 | .iter() 182 | .flat_map(|el| el.lines().iter()) 183 | .cloned() 184 | .collect::>() 185 | .into() 186 | } 187 | -------------------------------------------------------------------------------- /foucault-client/src/states/note_tags_managing.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::Result; 4 | use log::info; 5 | 6 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 7 | use ratatui::{ 8 | prelude::{Constraint, Direction, Layout, Rect}, 9 | style::{Color, Style}, 10 | text::{Line, Span}, 11 | widgets::{Block, BorderType, Borders, Clear, List, ListState, Padding, Paragraph}, 12 | Frame, 13 | }; 14 | 15 | use crate::{ 16 | helpers::create_bottom_help_bar, 17 | note::Note, 18 | states::{ 19 | note_tag_addition::NoteTagAdditionStateData, note_tag_deletion::NoteTagDeletionStateData, 20 | note_viewing::NoteViewingStateData, tag_notes_listing::TagNotesListingStateData, State, 21 | }, 22 | tag::Tag, 23 | NotebookAPI, 24 | }; 25 | 26 | #[derive(Clone)] 27 | pub struct NoteTagsManagingStateData { 28 | pub note: Note, 29 | tags: Arc<[Tag]>, 30 | selected: usize, 31 | help_display: bool, 32 | } 33 | 34 | impl NoteTagsManagingStateData { 35 | pub async fn new(note: Note, notebook: &NotebookAPI) -> Result { 36 | Ok(NoteTagsManagingStateData { 37 | tags: note.tags(notebook).await?.into(), 38 | note, 39 | selected: 0, 40 | help_display: false, 41 | }) 42 | } 43 | 44 | pub fn get_selected(&self) -> Option<&Tag> { 45 | self.tags.get(self.selected) 46 | } 47 | } 48 | 49 | pub async fn run_note_tags_managing_state( 50 | mut state_data: NoteTagsManagingStateData, 51 | key_event: KeyEvent, 52 | notebook: &NotebookAPI, 53 | ) -> Result { 54 | Ok(match key_event.code { 55 | KeyCode::Esc => { 56 | info!("Quit note {} tags managing.", state_data.note.name()); 57 | State::NoteViewing(NoteViewingStateData::new(state_data.note, notebook).await?) 58 | } 59 | KeyCode::Char('h') if key_event.modifiers == KeyModifiers::CONTROL => { 60 | info!("Toogle the help display."); 61 | state_data.help_display = !state_data.help_display; 62 | 63 | State::NoteTagsManaging(state_data) 64 | } 65 | KeyCode::Char('d') if !state_data.tags.is_empty() && notebook.permissions.writable() => { 66 | info!( 67 | "Open note {} tag {} deletion prompt.", 68 | state_data.note.name(), 69 | state_data 70 | .get_selected() 71 | .expect("A tag should be selected.") 72 | .name() 73 | ); 74 | State::NoteTagDeletion(NoteTagDeletionStateData::empty(state_data)) 75 | } 76 | KeyCode::Char('a') if notebook.permissions.writable() => { 77 | info!("Open note {} tag adding prompt.", state_data.note.name()); 78 | State::NoteTagAddition(NoteTagAdditionStateData::empty(state_data)) 79 | } 80 | KeyCode::Enter if !state_data.tags.is_empty() => { 81 | info!( 82 | "Open the listing of notes related to tag {}.", 83 | state_data 84 | .get_selected() 85 | .expect("A tag should be selected.") 86 | .name() 87 | ); 88 | State::TagNotesListing( 89 | TagNotesListingStateData::new( 90 | state_data.tags[state_data.selected].clone(), 91 | notebook, 92 | ) 93 | .await?, 94 | ) 95 | } 96 | KeyCode::Up if state_data.selected > 0 => { 97 | State::NoteTagsManaging(NoteTagsManagingStateData { 98 | selected: state_data.selected - 1, 99 | ..state_data 100 | }) 101 | } 102 | KeyCode::Down if state_data.selected < state_data.tags.len().saturating_sub(1) => { 103 | State::NoteTagsManaging(NoteTagsManagingStateData { 104 | selected: state_data.selected + 1, 105 | ..state_data 106 | }) 107 | } 108 | _ => State::NoteTagsManaging(state_data), 109 | }) 110 | } 111 | 112 | pub fn draw_note_tags_managing_state( 113 | NoteTagsManagingStateData { 114 | note, 115 | tags, 116 | selected, 117 | help_display, 118 | }: &NoteTagsManagingStateData, 119 | notebook: &NotebookAPI, 120 | frame: &mut Frame, 121 | main_rect: Rect, 122 | ) { 123 | let vertical_layout = Layout::new( 124 | Direction::Vertical, 125 | [Constraint::Length(5), Constraint::Min(0)], 126 | ) 127 | .split(main_rect); 128 | 129 | let note_name = Paragraph::new(Line::from(vec![ 130 | Span::raw(note.name()).style(Style::new().fg(Color::Green)) 131 | ])) 132 | .block( 133 | Block::new() 134 | .title("Note name") 135 | .borders(Borders::ALL) 136 | .border_type(BorderType::Rounded) 137 | .border_style(Style::new().fg(Color::Blue)) 138 | .padding(Padding::uniform(1)), 139 | ); 140 | 141 | let note_tags = List::new(tags.iter().map(|tag| Span::raw(tag.name()))) 142 | .highlight_symbol(">> ") 143 | .highlight_style(Style::new().fg(Color::Black).bg(Color::White)) 144 | .block( 145 | Block::new() 146 | .title("Note Tags") 147 | .borders(Borders::ALL) 148 | .border_type(BorderType::Rounded) 149 | .border_style(Style::new().fg(Color::Yellow)) 150 | .padding(Padding::uniform(1)), 151 | ); 152 | 153 | frame.render_widget(note_name, vertical_layout[0]); 154 | frame.render_stateful_widget( 155 | note_tags, 156 | vertical_layout[1], 157 | &mut ListState::default().with_selected(Some(*selected)), 158 | ); 159 | 160 | if *help_display { 161 | let writing_op_color = if notebook.permissions.writable() { 162 | Color::Blue 163 | } else { 164 | Color::Red 165 | }; 166 | let (commands, commands_area) = create_bottom_help_bar( 167 | &[ 168 | ("a", writing_op_color, "Add tag"), 169 | ("d", writing_op_color, "Delete tag"), 170 | ("⏎", Color::Blue, "List related notes"), 171 | ], 172 | 3, 173 | main_rect, 174 | ); 175 | 176 | frame.render_widget(Clear, commands_area); 177 | frame.render_widget(commands, commands_area); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /foucault-client/src/states.rs: -------------------------------------------------------------------------------- 1 | pub mod error; 2 | mod note_creation; 3 | mod note_deletion; 4 | mod note_renaming; 5 | mod note_tag_addition; 6 | mod note_tag_deletion; 7 | mod note_tags_managing; 8 | mod note_viewing; 9 | mod notes_managing; 10 | mod nothing; 11 | mod tag_creation; 12 | mod tag_deletion; 13 | mod tag_notes_listing; 14 | mod tag_renaming; 15 | mod tags_managing; 16 | 17 | use anyhow::Result; 18 | 19 | use crossterm::event::KeyEvent; 20 | use ratatui::{layout::Rect, Frame}; 21 | use tag_renaming::{draw_tag_renaming_state, run_tag_renaming_state, TagRenamingStateData}; 22 | 23 | use crate::{ 24 | states::{ 25 | error::{draw_error_state, run_error_state, ErrorStateData}, 26 | note_creation::{draw_note_creation_state, run_note_creation_state, NoteCreationStateData}, 27 | note_deletion::{draw_note_deletion_state, run_note_deletion_state, NoteDeletionStateData}, 28 | note_renaming::{draw_note_renaming_state, run_note_renaming_state, NoteRenamingStateData}, 29 | note_tag_addition::{ 30 | draw_note_tag_addition_state, run_note_tag_addition_state, NoteTagAdditionStateData, 31 | }, 32 | note_tag_deletion::{ 33 | draw_note_tag_deletion_state, run_note_tag_deletion_state, NoteTagDeletionStateData, 34 | }, 35 | note_tags_managing::{ 36 | draw_note_tags_managing_state, run_note_tags_managing_state, NoteTagsManagingStateData, 37 | }, 38 | note_viewing::{draw_note_viewing_state, run_note_viewing_state, NoteViewingStateData}, 39 | notes_managing::{ 40 | draw_notes_managing_state, run_note_managing_state, NotesManagingStateData, 41 | }, 42 | nothing::{draw_nothing_state, run_nothing_state}, 43 | tag_creation::{draw_tag_creation_state, run_tag_creation_state, TagsCreationStateData}, 44 | tag_deletion::{draw_tag_deletion_state, run_tag_deletion_state, TagsDeletionStateData}, 45 | tag_notes_listing::{ 46 | draw_tag_notes_listing_state, run_tag_notes_listing_state, TagNotesListingStateData, 47 | }, 48 | tags_managing::{draw_tags_managing_state, run_tags_managing_state, TagsManagingStateData}, 49 | }, 50 | NotebookAPI, 51 | }; 52 | 53 | #[derive(Clone)] 54 | pub enum State { 55 | Nothing, 56 | Exit, 57 | Error(ErrorStateData), 58 | NotesManaging(NotesManagingStateData), 59 | NoteViewing(NoteViewingStateData), 60 | NoteCreation(NoteCreationStateData), 61 | NoteDeletion(NoteDeletionStateData), 62 | NoteRenaming(NoteRenamingStateData), 63 | NoteTagsManaging(NoteTagsManagingStateData), 64 | NoteTagDeletion(NoteTagDeletionStateData), 65 | NoteTagAddition(NoteTagAdditionStateData), 66 | TagsManaging(TagsManagingStateData), 67 | TagCreation(TagsCreationStateData), 68 | TagDeletion(TagsDeletionStateData), 69 | TagRenaming(TagRenamingStateData), 70 | TagNotesListing(TagNotesListingStateData), 71 | } 72 | 73 | impl State { 74 | pub async fn run( 75 | self, 76 | key_event: KeyEvent, 77 | notebook: &NotebookAPI, 78 | force_redraw: &mut bool, 79 | ) -> Result { 80 | match self { 81 | State::Nothing => run_nothing_state(key_event, notebook).await, 82 | State::Error(data) => run_error_state(data, key_event).await, 83 | State::NotesManaging(data) => run_note_managing_state(data, key_event, notebook).await, 84 | State::NoteCreation(data) => run_note_creation_state(data, key_event, notebook).await, 85 | State::NoteViewing(data) => { 86 | run_note_viewing_state(data, key_event, notebook, force_redraw).await 87 | } 88 | State::NoteDeletion(data) => run_note_deletion_state(data, key_event, notebook).await, 89 | State::NoteRenaming(data) => run_note_renaming_state(data, key_event, notebook).await, 90 | State::NoteTagsManaging(data) => { 91 | run_note_tags_managing_state(data, key_event, notebook).await 92 | } 93 | State::NoteTagAddition(data) => { 94 | run_note_tag_addition_state(data, key_event, notebook).await 95 | } 96 | State::NoteTagDeletion(data) => { 97 | run_note_tag_deletion_state(data, key_event, notebook).await 98 | } 99 | State::TagsManaging(data) => run_tags_managing_state(data, key_event, notebook).await, 100 | State::TagCreation(data) => run_tag_creation_state(data, key_event, notebook).await, 101 | State::TagDeletion(data) => run_tag_deletion_state(data, key_event, notebook).await, 102 | State::TagRenaming(data) => run_tag_renaming_state(data, key_event, notebook).await, 103 | State::TagNotesListing(data) => { 104 | run_tag_notes_listing_state(data, key_event, notebook).await 105 | } 106 | State::Exit => unreachable!(), 107 | } 108 | } 109 | 110 | pub fn draw(&self, notebook: &NotebookAPI, frame: &mut Frame, main_rect: Rect) { 111 | match self { 112 | State::Nothing => draw_nothing_state(notebook, frame, main_rect), 113 | State::Error(data) => draw_error_state(notebook, data, frame, main_rect), 114 | State::NotesManaging(data) => { 115 | draw_notes_managing_state(data, notebook, frame, main_rect); 116 | } 117 | State::NoteCreation(data) => draw_note_creation_state(data, notebook, frame, main_rect), 118 | State::NoteViewing(data) => draw_note_viewing_state(data, notebook, frame, main_rect), 119 | State::NoteDeletion(data) => draw_note_deletion_state(data, notebook, frame, main_rect), 120 | State::NoteRenaming(data) => draw_note_renaming_state(data, notebook, frame, main_rect), 121 | State::NoteTagsManaging(data) => { 122 | draw_note_tags_managing_state(data, notebook, frame, main_rect); 123 | } 124 | State::NoteTagAddition(data) => { 125 | draw_note_tag_addition_state(data, notebook, frame, main_rect); 126 | } 127 | State::NoteTagDeletion(data) => { 128 | draw_note_tag_deletion_state(data, notebook, frame, main_rect); 129 | } 130 | State::TagsManaging(data) => draw_tags_managing_state(data, notebook, frame, main_rect), 131 | State::TagCreation(data) => draw_tag_creation_state(data, notebook, frame, main_rect), 132 | State::TagDeletion(data) => draw_tag_deletion_state(data, notebook, frame, main_rect), 133 | State::TagRenaming(data) => draw_tag_renaming_state(data, notebook, frame, main_rect), 134 | State::TagNotesListing(data) => draw_tag_notes_listing_state(data, frame, main_rect), 135 | State::Exit => unreachable!(), 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /foucault-client/src/states/tag_notes_listing.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::Result; 4 | use log::{info, warn}; 5 | 6 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 7 | use ratatui::{ 8 | layout::Rect, 9 | prelude::{Constraint, Direction, Layout, Margin}, 10 | style::{Color, Modifier, Style}, 11 | text::{Line, Span}, 12 | widgets::{ 13 | Block, BorderType, Borders, List, ListState, Padding, Paragraph, Scrollbar, 14 | ScrollbarOrientation, ScrollbarState, 15 | }, 16 | Frame, 17 | }; 18 | 19 | use crate::{ 20 | helpers::EditableText, 21 | note::{Note, NoteSummary}, 22 | states::{note_viewing::NoteViewingStateData, State}, 23 | tag::Tag, 24 | NotebookAPI, 25 | }; 26 | 27 | #[derive(Clone)] 28 | pub struct TagNotesListingStateData { 29 | tag: Tag, 30 | pattern: EditableText, 31 | selected: usize, 32 | notes: Arc<[NoteSummary]>, 33 | } 34 | 35 | impl TagNotesListingStateData { 36 | pub async fn new(tag: Tag, notebook: &NotebookAPI) -> Result { 37 | Ok(TagNotesListingStateData { 38 | notes: NoteSummary::search_with_tag(tag.id(), "", notebook) 39 | .await? 40 | .into(), 41 | pattern: EditableText::new(String::new()), 42 | selected: 0, 43 | tag, 44 | }) 45 | } 46 | } 47 | 48 | pub async fn run_tag_notes_listing_state( 49 | mut state_data: TagNotesListingStateData, 50 | key_event: KeyEvent, 51 | notebook: &NotebookAPI, 52 | ) -> Result { 53 | Ok(match key_event.code { 54 | KeyCode::Esc => { 55 | info!( 56 | "Quit the listing of notes related to tag {}.", 57 | state_data.tag.name() 58 | ); 59 | State::Nothing 60 | } 61 | KeyCode::Enter if !state_data.notes.is_empty() => { 62 | let summary = &state_data.notes[state_data.selected]; 63 | if let Some(note) = Note::load_by_id(summary.id(), notebook).await? { 64 | info!("Open note {}.", note.name()); 65 | State::NoteViewing(NoteViewingStateData::new(note, notebook).await?) 66 | } else { 67 | State::TagNotesListing(state_data) 68 | } 69 | } 70 | KeyCode::Backspace if key_event.modifiers == KeyModifiers::NONE => { 71 | state_data.pattern.remove_char(); 72 | state_data.notes = 73 | NoteSummary::search_with_tag(state_data.tag.id(), &state_data.pattern, notebook) 74 | .await? 75 | .into(); 76 | state_data.selected = 0; 77 | 78 | State::TagNotesListing(state_data) 79 | } 80 | KeyCode::Char(c) if key_event.modifiers == KeyModifiers::NONE => { 81 | state_data.pattern.insert_char(c); 82 | state_data.notes = 83 | NoteSummary::search_with_tag(state_data.tag.id(), &state_data.pattern, notebook) 84 | .await? 85 | .into(); 86 | state_data.selected = 0; 87 | 88 | State::TagNotesListing(state_data) 89 | } 90 | KeyCode::Up if state_data.selected > 0 => { 91 | state_data.selected -= 1; 92 | State::TagNotesListing(state_data) 93 | } 94 | KeyCode::Down if state_data.selected < state_data.notes.len().saturating_sub(1) => { 95 | state_data.selected += 1; 96 | State::TagNotesListing(state_data) 97 | } 98 | _ => State::TagNotesListing(state_data), 99 | }) 100 | } 101 | 102 | pub fn draw_tag_notes_listing_state( 103 | TagNotesListingStateData { 104 | tag, 105 | pattern, 106 | notes, 107 | selected, 108 | }: &TagNotesListingStateData, 109 | frame: &mut Frame, 110 | main_rect: Rect, 111 | ) { 112 | let vertical_layout = Layout::new( 113 | Direction::Vertical, 114 | [Constraint::Length(5), Constraint::Min(0)], 115 | ) 116 | .split(main_rect); 117 | 118 | let horizontal_layout = Layout::new( 119 | Direction::Horizontal, 120 | [Constraint::Percentage(20), Constraint::Min(0)], 121 | ) 122 | .split(vertical_layout[0]); 123 | 124 | let tag_name = Paragraph::new(Line::from(vec![Span::raw(tag.name()).style( 125 | Style::new() 126 | .bg(Color::from_u32(tag.color())) 127 | .fg(Color::White) 128 | .add_modifier(Modifier::BOLD), 129 | )])) 130 | .block( 131 | Block::new() 132 | .title("Tag name") 133 | .borders(Borders::ALL) 134 | .border_type(BorderType::Rounded) 135 | .border_style(Style::new().fg(Color::Blue)) 136 | .padding(Padding::uniform(1)), 137 | ); 138 | 139 | let search_bar = pattern.build_paragraph().block( 140 | Block::new() 141 | .title("Filter") 142 | .borders(Borders::ALL) 143 | .border_type(BorderType::Rounded) 144 | .border_style(Style::new().fg(if notes.is_empty() { 145 | Color::Red 146 | } else { 147 | Color::Green 148 | })) 149 | .padding(Padding::uniform(1)), 150 | ); 151 | 152 | let tag_notes = List::new(notes.iter().map(|note| { 153 | Line::from( 154 | if let Some(pattern_start) = note.name().to_lowercase().find(&pattern.to_lowercase()) { 155 | let pattern_end = pattern_start + pattern.len(); 156 | vec![ 157 | Span::raw(¬e.name()[..pattern_start]) 158 | .style(Style::new().add_modifier(Modifier::BOLD)), 159 | Span::raw(¬e.name()[pattern_start..pattern_end]).style( 160 | Style::new() 161 | .add_modifier(Modifier::BOLD) 162 | .add_modifier(Modifier::UNDERLINED), 163 | ), 164 | Span::raw(¬e.name()[pattern_end..]) 165 | .style(Style::new().add_modifier(Modifier::BOLD)), 166 | ] 167 | } else { 168 | warn!( 169 | "The search pattern '{}' did not match on note &{}", 170 | &**pattern, 171 | note.name() 172 | ); 173 | vec![Span::raw(note.name())] 174 | }, 175 | ) 176 | })) 177 | .highlight_symbol(">> ") 178 | .highlight_style(Style::new().fg(Color::Black).bg(Color::White)) 179 | .block( 180 | Block::new() 181 | .title("Related notes") 182 | .borders(Borders::ALL) 183 | .border_type(BorderType::Rounded) 184 | .border_style(Style::new().fg(Color::Yellow)) 185 | .padding(Padding::uniform(1)), 186 | ); 187 | 188 | let notes_scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) 189 | .begin_symbol(Some("↑")) 190 | .end_symbol(Some("↓")); 191 | 192 | frame.render_widget(tag_name, horizontal_layout[0]); 193 | frame.render_widget(search_bar, horizontal_layout[1]); 194 | frame.render_stateful_widget( 195 | tag_notes, 196 | vertical_layout[1], 197 | &mut ListState::default().with_selected(Some(*selected)), 198 | ); 199 | frame.render_stateful_widget( 200 | notes_scrollbar, 201 | vertical_layout[1].inner(Margin::new(0, 1)), 202 | &mut ScrollbarState::new(notes.len()).position(*selected), 203 | ); 204 | } 205 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![deny(clippy::pedantic)] 2 | #![warn(unused_crate_dependencies)] 3 | #![allow(clippy::too_many_lines)] 4 | #![allow(clippy::missing_panics_doc)] 5 | #![allow(clippy::missing_errors_doc)] 6 | #![allow(clippy::module_name_repetitions)] 7 | 8 | mod notebook_selector; 9 | 10 | use std::{env, sync::Arc}; 11 | 12 | use log::info; 13 | 14 | use tokio::fs; 15 | 16 | use clap::{crate_version, Parser, Subcommand}; 17 | use question::{Answer, Question}; 18 | 19 | use foucault_client::{explore::explore, NotebookAPI, APP_DIR_PATH}; 20 | use foucault_core::{permissions::Permissions, pretty_error, PrettyError}; 21 | use foucault_server::notebook::Notebook; 22 | 23 | use crate::notebook_selector::open_selector; 24 | 25 | pub const LOCAL_ADRESS: &str = "0.0.0.0"; 26 | pub const DEFAULT_PORT: u16 = 8078; 27 | 28 | #[derive(Parser)] 29 | #[command( 30 | author = "Adrien Degliame ", 31 | version = crate_version!(), 32 | about = "The Foucault notebook CLI" 33 | )] 34 | struct Cli { 35 | #[command(subcommand)] 36 | command: Option, 37 | } 38 | 39 | #[derive(Subcommand)] 40 | enum Commands { 41 | #[command(about = "Create a new notebook")] 42 | Create { 43 | #[arg(help = "The new notebook's name")] 44 | name: String, 45 | #[arg(short, long, help = "Create the notebook in the current directory")] 46 | local: bool, 47 | }, 48 | #[command(about = "Open a notebook")] 49 | Open { 50 | #[arg(help = "The name of the notebook to open")] 51 | name: String, 52 | #[arg( 53 | short, 54 | long, 55 | help = "The internal port that should be used by foucault" 56 | )] 57 | port: Option, 58 | }, 59 | #[command(about = "Serve a notebook for remote connection")] 60 | Serve { 61 | #[arg(help = "The name of the notebook to serve")] 62 | name: String, 63 | #[arg(short, long, help = "The port on which the notebook should be exposed")] 64 | port: Option, 65 | #[arg(long, help = "Only allow read only operations")] 66 | read_only: bool, 67 | }, 68 | #[command(about = "Connect to a remote notebook")] 69 | Connect { 70 | #[arg(help = "The address at which the notebook is hosted")] 71 | endpoint: String, 72 | }, 73 | #[command(about = "Delete a notebook")] 74 | Delete { 75 | #[arg(help = "The name of the notebook to delete")] 76 | name: String, 77 | }, 78 | } 79 | 80 | #[tokio::main] 81 | async fn main() { 82 | env_logger::init(); 83 | 84 | info!("Start foucault"); 85 | 86 | if !APP_DIR_PATH.exists() { 87 | if fs::create_dir(&*APP_DIR_PATH).await.is_err() { 88 | pretty_error!("Unable to create the app's directory."); 89 | todo!(); 90 | } 91 | } else if !APP_DIR_PATH.is_dir() { 92 | pretty_error!("Another file already exists."); 93 | todo!(); 94 | } 95 | 96 | let cli = Cli::parse(); 97 | 98 | if let Some(command) = &cli.command { 99 | match command { 100 | Commands::Create { name, local } => { 101 | info!("Create notebook {name}."); 102 | if *local { 103 | Notebook::new_notebook( 104 | name.trim(), 105 | &env::current_dir().expect("The current directory isn't accessible"), 106 | ) 107 | .await 108 | .pretty_unwrap(); 109 | } else { 110 | Notebook::new_notebook(name.trim(), &APP_DIR_PATH) 111 | .await 112 | .pretty_unwrap(); 113 | }; 114 | println!("Notebook {name} was successfully created."); 115 | } 116 | Commands::Open { name, port } => { 117 | info!("Open notebook {name}."); 118 | let notebook = Arc::new( 119 | Notebook::open_notebook(name, &APP_DIR_PATH) 120 | .await 121 | .pretty_unwrap(), 122 | ); 123 | let endpoint = format!("http://{LOCAL_ADRESS}:{}", port.unwrap_or(DEFAULT_PORT)); 124 | tokio::spawn(foucault_server::serve( 125 | notebook, 126 | Permissions::ReadWrite, 127 | port.unwrap_or(DEFAULT_PORT), 128 | )); 129 | let notebook_api = NotebookAPI::new(endpoint).await.pretty_unwrap(); 130 | explore(¬ebook_api).await.pretty_unwrap(); 131 | } 132 | Commands::Connect { endpoint } => { 133 | info!("Connect to a notebook at address {endpoint}."); 134 | let notebook_api = NotebookAPI::new(endpoint.clone()).await.pretty_unwrap(); 135 | explore(¬ebook_api).await.pretty_unwrap(); 136 | } 137 | Commands::Serve { 138 | name, 139 | read_only, 140 | port, 141 | } => { 142 | info!("Open notebook {name}."); 143 | let notebook = Arc::new( 144 | Notebook::open_notebook(name, &APP_DIR_PATH) 145 | .await 146 | .pretty_unwrap(), 147 | ); 148 | println!( 149 | "Serving notebook {} at {LOCAL_ADRESS}:{}", 150 | ¬ebook.name, 151 | port.unwrap_or(DEFAULT_PORT) 152 | ); 153 | foucault_server::serve( 154 | notebook, 155 | if *read_only { 156 | Permissions::ReadOnly 157 | } else { 158 | Permissions::ReadWrite 159 | }, 160 | port.unwrap_or(DEFAULT_PORT), 161 | ) 162 | .await 163 | .pretty_unwrap(); 164 | } 165 | Commands::Delete { name } => { 166 | info!("Delete notebook {name}."); 167 | if matches!( 168 | Question::new(&format!( 169 | "Are you sure you want to delete notebook {name} ?", 170 | )) 171 | .default(Answer::NO) 172 | .show_defaults() 173 | .confirm(), 174 | Answer::YES 175 | ) { 176 | println!("Proceed."); 177 | Notebook::delete_notebook(name, &APP_DIR_PATH) 178 | .await 179 | .pretty_unwrap(); 180 | } else { 181 | println!("Cancel."); 182 | } 183 | } 184 | } 185 | } else { 186 | info!("Open the default notebook selector."); 187 | 188 | if let Some(name) = open_selector(&APP_DIR_PATH).pretty_unwrap() { 189 | info!("Open the notebook selected : {name}."); 190 | let notebook = Arc::new( 191 | Notebook::open_notebook(name.as_str(), &APP_DIR_PATH) 192 | .await 193 | .pretty_unwrap(), 194 | ); 195 | let endpoint = format!("http://{LOCAL_ADRESS}:{DEFAULT_PORT}"); 196 | tokio::spawn(foucault_server::serve( 197 | notebook, 198 | Permissions::ReadWrite, 199 | DEFAULT_PORT, 200 | )); 201 | let notebook_api = NotebookAPI::new(endpoint).await.pretty_unwrap(); 202 | explore(¬ebook_api).await.pretty_unwrap(); 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /foucault-client/src/states/tags_managing.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::Result; 4 | use log::{info, warn}; 5 | 6 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 7 | use ratatui::{ 8 | prelude::{Constraint, Direction, Layout, Margin, Rect}, 9 | style::{Color, Modifier, Style}, 10 | text::{Line, Span}, 11 | widgets::{ 12 | Block, BorderType, Borders, Clear, List, ListState, Padding, Scrollbar, 13 | ScrollbarOrientation, ScrollbarState, 14 | }, 15 | Frame, 16 | }; 17 | 18 | use crate::{ 19 | helpers::{create_bottom_help_bar, EditableText}, 20 | states::{ 21 | tag_creation::TagsCreationStateData, tag_deletion::TagsDeletionStateData, 22 | tag_notes_listing::TagNotesListingStateData, tag_renaming::TagRenamingStateData, State, 23 | }, 24 | tag::Tag, 25 | NotebookAPI, 26 | }; 27 | 28 | #[derive(Clone)] 29 | pub struct TagsManagingStateData { 30 | pub pattern: EditableText, 31 | selected: usize, 32 | tags: Arc<[Tag]>, 33 | help_display: bool, 34 | } 35 | 36 | impl TagsManagingStateData { 37 | pub async fn from_pattern(pattern: EditableText, notebook: &NotebookAPI) -> Result { 38 | Ok(TagsManagingStateData { 39 | tags: Tag::search_by_name(&pattern, notebook).await?.into(), 40 | selected: 0, 41 | pattern, 42 | help_display: false, 43 | }) 44 | } 45 | 46 | pub async fn empty(notebook: &NotebookAPI) -> Result { 47 | Self::from_pattern(EditableText::new(String::new()), notebook).await 48 | } 49 | 50 | pub fn get_selected(&self) -> Option<&Tag> { 51 | self.tags.get(self.selected) 52 | } 53 | } 54 | 55 | pub async fn run_tags_managing_state( 56 | mut state_data: TagsManagingStateData, 57 | key_event: KeyEvent, 58 | notebook: &NotebookAPI, 59 | ) -> Result { 60 | Ok(match key_event.code { 61 | KeyCode::Esc => { 62 | info!("Quit the tag manager."); 63 | State::Nothing 64 | } 65 | KeyCode::Char('h') if key_event.modifiers == KeyModifiers::CONTROL => { 66 | info!("Toogle the help display."); 67 | state_data.help_display = !state_data.help_display; 68 | 69 | State::TagsManaging(state_data) 70 | } 71 | KeyCode::Char('c') 72 | if key_event.modifiers == KeyModifiers::CONTROL && notebook.permissions.writable() => 73 | { 74 | info!("Open the tag creationg prompt."); 75 | State::TagCreation(TagsCreationStateData::empty(state_data)) 76 | } 77 | KeyCode::Char('d') 78 | if key_event.modifiers == KeyModifiers::CONTROL 79 | && !state_data.tags.is_empty() 80 | && notebook.permissions.writable() => 81 | { 82 | info!("Open the tag deletion prompt."); 83 | State::TagDeletion(TagsDeletionStateData::empty(state_data)) 84 | } 85 | KeyCode::Char('r') 86 | if key_event.modifiers == KeyModifiers::CONTROL 87 | && !state_data.tags.is_empty() 88 | && notebook.permissions.writable() => 89 | { 90 | info!("Open the tag renaming prompt."); 91 | State::TagRenaming(TagRenamingStateData::empty(state_data)) 92 | } 93 | KeyCode::Up if state_data.selected > 0 => State::TagsManaging(TagsManagingStateData { 94 | selected: state_data.selected - 1, 95 | ..state_data 96 | }), 97 | KeyCode::Down if state_data.selected < state_data.tags.len().saturating_sub(1) => { 98 | State::TagsManaging(TagsManagingStateData { 99 | selected: state_data.selected + 1, 100 | ..state_data 101 | }) 102 | } 103 | KeyCode::Enter if !state_data.tags.is_empty() => { 104 | info!("Open the listing of the related notes."); 105 | let tag = state_data.tags[state_data.selected].clone(); 106 | 107 | State::TagNotesListing(TagNotesListingStateData::new(tag, notebook).await?) 108 | } 109 | KeyCode::Backspace if key_event.modifiers == KeyModifiers::NONE => { 110 | state_data.pattern.remove_char(); 111 | state_data.tags = Tag::search_by_name(&state_data.pattern, notebook) 112 | .await? 113 | .into(); 114 | state_data.selected = 0; 115 | State::TagsManaging(state_data) 116 | } 117 | KeyCode::Char(c) if key_event.modifiers == KeyModifiers::NONE && !c.is_whitespace() => { 118 | state_data.pattern.insert_char(c); 119 | state_data.tags = Tag::search_by_name(&state_data.pattern, notebook) 120 | .await? 121 | .into(); 122 | state_data.selected = 0; 123 | State::TagsManaging(state_data) 124 | } 125 | _ => State::TagsManaging(state_data), 126 | }) 127 | } 128 | 129 | pub fn draw_tags_managing_state( 130 | TagsManagingStateData { 131 | pattern, 132 | selected, 133 | tags, 134 | help_display, 135 | }: &TagsManagingStateData, 136 | notebook: &NotebookAPI, 137 | frame: &mut Frame, 138 | main_rect: Rect, 139 | ) { 140 | let vertical_layout = Layout::new( 141 | Direction::Vertical, 142 | [Constraint::Length(5), Constraint::Min(0)], 143 | ) 144 | .split(main_rect); 145 | 146 | let filter_bar = pattern.build_paragraph().block( 147 | Block::new() 148 | .title("Filter") 149 | .borders(Borders::ALL) 150 | .border_type(BorderType::Rounded) 151 | .border_style(Style::new().fg(if tags.is_empty() { 152 | Color::Red 153 | } else { 154 | Color::Green 155 | })) 156 | .padding(Padding::uniform(1)), 157 | ); 158 | 159 | let list_results = List::new(tags.iter().map(|tag| { 160 | if let Some(pattern_start) = tag.name().to_lowercase().find(&pattern.to_lowercase()) { 161 | let pattern_end = pattern_start + pattern.len(); 162 | Line::from(vec![ 163 | Span::raw(&tag.name()[..pattern_start]), 164 | Span::raw(&tag.name()[pattern_start..pattern_end]) 165 | .style(Style::new().add_modifier(Modifier::UNDERLINED)), 166 | Span::raw(&tag.name()[pattern_end..]), 167 | ]) 168 | } else { 169 | warn!( 170 | "The search pattern '{}' did not match on tag {}", 171 | &**pattern, 172 | tag.name() 173 | ); 174 | Line::from(vec![Span::raw(tag.name())]) 175 | } 176 | })) 177 | .highlight_symbol(">> ") 178 | .highlight_style(Style::new().bg(Color::White).fg(Color::Black)) 179 | .block( 180 | Block::new() 181 | .title("Tags") 182 | .borders(Borders::ALL) 183 | .border_type(BorderType::Rounded) 184 | .border_style(Style::new().fg(Color::Yellow)) 185 | .padding(Padding::uniform(1)), 186 | ); 187 | 188 | let tags_scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) 189 | .begin_symbol(Some("↑")) 190 | .end_symbol(Some("↓")); 191 | 192 | frame.render_widget(filter_bar, vertical_layout[0]); 193 | frame.render_stateful_widget( 194 | list_results, 195 | vertical_layout[1], 196 | &mut ListState::with_selected(ListState::default(), Some(*selected)), 197 | ); 198 | frame.render_stateful_widget( 199 | tags_scrollbar, 200 | vertical_layout[1].inner(Margin::new(0, 1)), 201 | &mut ScrollbarState::new(tags.len()).position(*selected), 202 | ); 203 | 204 | if *help_display { 205 | let writing_op_color = if notebook.permissions.writable() { 206 | Color::Blue 207 | } else { 208 | Color::Red 209 | }; 210 | let (commands, commands_area) = create_bottom_help_bar( 211 | &[ 212 | ("Ctrl+c", writing_op_color, "Create tag"), 213 | ("Ctrl+d", writing_op_color, "Delete tag"), 214 | ("Ctrl+r", writing_op_color, "Rename tag"), 215 | ("⏎", Color::Blue, "List related notes"), 216 | ], 217 | 2, 218 | main_rect, 219 | ); 220 | 221 | frame.render_widget(Clear, commands_area); 222 | frame.render_widget(commands, commands_area); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /foucault-server/src/note_queries.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::{Error, Result}; 4 | use log::info; 5 | 6 | use futures::future::join_all; 7 | use sqlx::SqlitePool; 8 | 9 | use foucault_core::{ 10 | link_repr::Link, 11 | note_repr::{Note, NoteError, NoteSummary}, 12 | tag_repr::{Tag, TagError}, 13 | }; 14 | 15 | use crate::tag_queries; 16 | 17 | pub(crate) async fn create(name: &str, content: &str, connection: &SqlitePool) -> Result { 18 | info!("Insert note {name} in the notebook"); 19 | 20 | if let Some(err) = validate_name(name, connection).await? { 21 | return Err(err.into()); 22 | }; 23 | 24 | let id = sqlx::query!( 25 | "INSERT INTO notes_table (name, content) VALUES ($1, $2) RETURNING id", 26 | name, 27 | content 28 | ) 29 | .fetch_one(connection) 30 | .await? 31 | .id; 32 | 33 | Ok(id) 34 | } 35 | 36 | pub(crate) async fn validate_name( 37 | name: &str, 38 | connection: &SqlitePool, 39 | ) -> Result> { 40 | if name.is_empty() { 41 | Ok(Some(NoteError::EmptyName)) 42 | } else if name_exists(name, connection).await? { 43 | Ok(Some(NoteError::AlreadyExists)) 44 | } else { 45 | Ok(None) 46 | } 47 | } 48 | 49 | pub(crate) async fn name_exists(name: &str, connection: &SqlitePool) -> Result { 50 | Ok( 51 | sqlx::query!("SELECT id FROM notes_table WHERE name=$1", name) 52 | .fetch_optional(connection) 53 | .await? 54 | .is_some(), 55 | ) 56 | } 57 | 58 | pub(crate) async fn load_by_id(id: i64, connection: &SqlitePool) -> Result> { 59 | sqlx::query!("SELECT name,content FROM notes_table WHERE id=$1", id) 60 | .fetch_optional(connection) 61 | .await? 62 | .map(|row| { 63 | Ok(Note { 64 | id, 65 | name: Arc::from(row.name.expect("There should be a note name")), 66 | content: Arc::from(row.content.expect("There should be a note content")), 67 | }) 68 | }) 69 | .transpose() 70 | } 71 | 72 | pub(crate) async fn load_by_name(name: String, connection: &SqlitePool) -> Result> { 73 | sqlx::query!("SELECT id, content FROM notes_table WHERE name=$1", name) 74 | .fetch_optional(connection) 75 | .await? 76 | .map(|row| { 77 | Ok(Note { 78 | id: row.id.expect("There should be a note id"), 79 | name: Arc::from(name), 80 | content: Arc::from(row.content.expect("There should be a note content")), 81 | }) 82 | }) 83 | .transpose() 84 | } 85 | 86 | pub(crate) async fn list_links(id: i64, connection: &SqlitePool) -> Result> { 87 | sqlx::query!( 88 | "SELECT to_name FROM links_table WHERE from_id=$1 ORDER BY to_name ASC", 89 | id 90 | ) 91 | .fetch_all(connection) 92 | .await? 93 | .into_iter() 94 | .map(|row| { 95 | Ok(Link { 96 | from: id, 97 | to: row.to_name, 98 | }) 99 | }) 100 | .collect() 101 | } 102 | 103 | pub async fn list_tags(id: i64, connection: &SqlitePool) -> Result> { 104 | sqlx::query!( 105 | "SELECT tags_table.id,tags_table.name,tags_table.color FROM tags_join_table INNER JOIN tags_table ON tags_join_table.tag_id = tags_table.id WHERE tags_join_table.note_id=$1 ORDER BY tags_table.name ASC", 106 | id 107 | ) 108 | .fetch_all(connection) 109 | .await? 110 | .into_iter() 111 | .map(|row| Ok(Tag { 112 | id: row.id, 113 | name: Arc::from(row.name), 114 | color: u32::try_from(row.color)?, 115 | })) 116 | .collect() 117 | } 118 | 119 | pub(crate) async fn has_tag(id: i64, tag_id: i64, connection: &SqlitePool) -> Result { 120 | Ok(sqlx::query!( 121 | "SELECT tag_id FROM tags_join_table WHERE tag_id=$1 AND note_id=$2", 122 | tag_id, 123 | id 124 | ) 125 | .fetch_optional(connection) 126 | .await? 127 | .is_some()) 128 | } 129 | 130 | pub(crate) async fn rename(id: i64, name: &str, connection: &SqlitePool) -> Result<()> { 131 | validate_name(name, connection).await?; 132 | 133 | sqlx::query!("UPDATE notes_table SET name=$1 WHERE id=$2", name, id) 134 | .execute(connection) 135 | .await?; 136 | 137 | Ok(()) 138 | } 139 | 140 | pub(crate) async fn delete(id: i64, connection: &SqlitePool) -> Result<()> { 141 | sqlx::query!("DELETE FROM notes_table WHERE id=$1", id) 142 | .execute(connection) 143 | .await?; 144 | 145 | Ok(()) 146 | } 147 | 148 | pub(crate) async fn update_content(id: i64, content: &str, connection: &SqlitePool) -> Result<()> { 149 | sqlx::query!("UPDATE notes_table SET content=$1 WHERE id=$2", content, id) 150 | .execute(connection) 151 | .await?; 152 | Ok(()) 153 | } 154 | 155 | pub(crate) async fn update_links( 156 | id: i64, 157 | new_links: &[Link], 158 | connection: &SqlitePool, 159 | ) -> Result<()> { 160 | let current_links = list_links(id, connection).await?; 161 | 162 | join_all( 163 | current_links 164 | .iter() 165 | .filter(|link| !new_links.contains(link)) 166 | .map(|link| { 167 | sqlx::query!( 168 | "DELETE FROM links_table WHERE from_id=$1 AND to_name=$2", 169 | id, 170 | link.to 171 | ) 172 | .execute(connection) 173 | }) 174 | .collect::>(), 175 | ) 176 | .await; 177 | 178 | join_all( 179 | new_links 180 | .iter() 181 | .filter(|link| !current_links.contains(link)) 182 | .map(|link| { 183 | sqlx::query!( 184 | "INSERT INTO links_table (from_id, to_name) VALUES ($1, $2)", 185 | id, 186 | link.to 187 | ) 188 | .execute(connection) 189 | }) 190 | .collect::>(), 191 | ) 192 | .await; 193 | 194 | Ok(()) 195 | } 196 | 197 | pub(crate) async fn validate_new_tag( 198 | id: i64, 199 | tag_id: i64, 200 | notebook: &SqlitePool, 201 | ) -> Result> { 202 | if !tag_queries::id_exists(tag_id, notebook).await? { 203 | Ok(Some(TagError::DoesNotExists.into())) 204 | } else if has_tag(id, tag_id, notebook).await? { 205 | Ok(Some(NoteError::NoteAlreadyTagged.into())) 206 | } else { 207 | Ok(None) 208 | } 209 | } 210 | 211 | pub(crate) async fn add_tag(id: i64, tag_id: i64, connection: &SqlitePool) -> Result<()> { 212 | if let Some(err) = validate_new_tag(id, tag_id, connection).await? { 213 | return Err(err); 214 | }; 215 | 216 | sqlx::query!( 217 | "INSERT INTO tags_join_table (note_id, tag_id) VALUES ($1, $2)", 218 | id, 219 | tag_id 220 | ) 221 | .execute(connection) 222 | .await?; 223 | 224 | Ok(()) 225 | } 226 | 227 | pub(crate) async fn remove_tag(id: i64, tag_id: i64, connection: &SqlitePool) -> Result<()> { 228 | sqlx::query!( 229 | "DELETE FROM tags_join_table WHERE note_id=$1 AND tag_id=$2", 230 | id, 231 | tag_id 232 | ) 233 | .execute(connection) 234 | .await?; 235 | 236 | Ok(()) 237 | } 238 | 239 | pub(crate) async fn search_by_name( 240 | pattern: &str, 241 | connection: &SqlitePool, 242 | ) -> Result> { 243 | let sql_pattern = format!("%{pattern}%"); 244 | join_all( 245 | sqlx::query!( 246 | "SELECT id, name FROM notes_table WHERE name LIKE $1 ORDER BY name ASC", 247 | sql_pattern 248 | ) 249 | .fetch_all(connection) 250 | .await? 251 | .into_iter() 252 | .map(|row| async move { 253 | let id = row.id.expect("There should be a note id"); 254 | Ok(NoteSummary { 255 | id, 256 | name: Arc::from(row.name.expect("There should be a note name")), 257 | tags: list_tags(id, connection).await?, 258 | }) 259 | }), 260 | ) 261 | .await 262 | .into_iter() 263 | .collect() 264 | } 265 | 266 | pub(crate) async fn search_with_tag( 267 | tag_id: i64, 268 | pattern: &str, 269 | connection: &SqlitePool, 270 | ) -> Result> { 271 | let sql_pattern = format!("%{pattern}%"); 272 | join_all( 273 | sqlx::query!( 274 | "SELECT notes_table.id, notes_table.name FROM tags_join_table INNER JOIN notes_table ON tags_join_table.note_id = notes_table.id WHERE tag_id=$1 AND notes_table.name LIKE $2 ORDER BY notes_table.name ASC", 275 | tag_id, 276 | sql_pattern 277 | ) 278 | .fetch_all(connection) 279 | .await?. 280 | into_iter() 281 | .map(|row| async move { 282 | Ok(NoteSummary { 283 | id: row.id, 284 | name: Arc::from(row.name.expect("There should be a note name")), 285 | tags: list_tags(row.id, connection).await? 286 | }) 287 | }) 288 | ) 289 | .await 290 | .into_iter() 291 | .collect() 292 | } 293 | -------------------------------------------------------------------------------- /foucault-server/src/note_api.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Json, State}, 3 | http::StatusCode, 4 | }; 5 | 6 | use serde_error::Error; 7 | 8 | use foucault_core::{ 9 | api::note::{ 10 | AddTagParam, CreateParam, RemoveTagParam, RenameParam, SearchWithTagParam, 11 | UpdateContentParam, UpdateLinksParam, ValidateNewTagParam, 12 | }, 13 | note_repr::{Note, NoteError, NoteSummary}, 14 | pretty_error, 15 | tag_repr::{Tag, TagError}, 16 | }; 17 | 18 | use crate::{error::FailibleJsonResult, note_queries, AppState}; 19 | 20 | pub(crate) async fn create( 21 | State(state): State, 22 | Json(CreateParam { name, content }): Json, 23 | ) -> FailibleJsonResult> { 24 | if !state.permissions.writable() { 25 | return Err(StatusCode::UNAUTHORIZED); 26 | } 27 | 28 | let res = note_queries::create(&name, &content, state.notebook.db()).await; 29 | 30 | match res { 31 | Ok(id) => Ok((StatusCode::OK, Json::from(Ok(id)))), 32 | Err(err) => { 33 | if let Some(note_err) = err.downcast_ref::() { 34 | Ok((StatusCode::NOT_ACCEPTABLE, Json::from(Err(*note_err)))) 35 | } else { 36 | pretty_error!("Error encountered during note creation : {}", err); 37 | Err(StatusCode::INTERNAL_SERVER_ERROR) 38 | } 39 | } 40 | } 41 | } 42 | 43 | pub(crate) async fn validate_name( 44 | State(state): State, 45 | Json(name): Json, 46 | ) -> FailibleJsonResult> { 47 | let res = note_queries::validate_name(&name, state.notebook.db()).await; 48 | 49 | match res { 50 | Ok(res) => Ok((StatusCode::OK, Json::from(res))), 51 | Err(err) => { 52 | pretty_error!("Error encountered during name validation : {err}"); 53 | Err(StatusCode::INTERNAL_SERVER_ERROR) 54 | } 55 | } 56 | } 57 | 58 | pub(crate) async fn load_by_id( 59 | State(state): State, 60 | Json(id): Json, 61 | ) -> FailibleJsonResult> { 62 | let res = note_queries::load_by_id(id, state.notebook.db()).await; 63 | 64 | match res { 65 | Ok(res) => Ok((StatusCode::OK, Json::from(res))), 66 | Err(err) => { 67 | pretty_error!("Error encountered during note loading : {err}"); 68 | Err(StatusCode::INTERNAL_SERVER_ERROR) 69 | } 70 | } 71 | } 72 | 73 | pub(crate) async fn load_by_name( 74 | State(state): State, 75 | Json(name): Json, 76 | ) -> FailibleJsonResult> { 77 | let res = note_queries::load_by_name(name, state.notebook.db()).await; 78 | 79 | match res { 80 | Ok(res) => Ok((StatusCode::OK, Json::from(res))), 81 | Err(err) => { 82 | pretty_error!("Error encountered during note loading : {err}"); 83 | Err(StatusCode::INTERNAL_SERVER_ERROR) 84 | } 85 | } 86 | } 87 | 88 | pub(crate) async fn rename( 89 | State(state): State, 90 | Json(RenameParam { id, name }): Json, 91 | ) -> FailibleJsonResult> { 92 | if !state.permissions.writable() { 93 | return Err(StatusCode::UNAUTHORIZED); 94 | } 95 | 96 | let res = note_queries::rename(id, &name, state.notebook.db()).await; 97 | 98 | match res { 99 | Ok(()) => Ok((StatusCode::OK, Json::from(None))), 100 | Err(err) => { 101 | if let Some(note_err) = err.downcast_ref::() { 102 | Ok((StatusCode::NOT_ACCEPTABLE, Json::from(Some(*note_err)))) 103 | } else { 104 | pretty_error!("Error encountered during note renaming : {err}"); 105 | Err(StatusCode::INTERNAL_SERVER_ERROR) 106 | } 107 | } 108 | } 109 | } 110 | 111 | pub(crate) async fn delete(State(state): State, Json(id): Json) -> StatusCode { 112 | if !state.permissions.writable() { 113 | return StatusCode::UNAUTHORIZED; 114 | } 115 | 116 | let res = note_queries::delete(id, state.notebook.db()).await; 117 | 118 | match res { 119 | Ok(()) => StatusCode::OK, 120 | Err(err) => { 121 | pretty_error!("Error encountered when deleting note : {err}"); 122 | StatusCode::INTERNAL_SERVER_ERROR 123 | } 124 | } 125 | } 126 | 127 | pub(crate) async fn update_content( 128 | State(state): State, 129 | Json(UpdateContentParam { id, content }): Json, 130 | ) -> StatusCode { 131 | if !state.permissions.writable() { 132 | return StatusCode::UNAUTHORIZED; 133 | } 134 | 135 | let res = note_queries::update_content(id, &content, state.notebook.db()).await; 136 | 137 | match res { 138 | Ok(()) => StatusCode::OK, 139 | Err(err) => { 140 | pretty_error!("Error encountered when updating note content : {err}"); 141 | StatusCode::INTERNAL_SERVER_ERROR 142 | } 143 | } 144 | } 145 | 146 | pub(crate) async fn update_links( 147 | State(state): State, 148 | Json(UpdateLinksParam { id, links }): Json, 149 | ) -> StatusCode { 150 | if !state.permissions.writable() { 151 | return StatusCode::UNAUTHORIZED; 152 | } 153 | 154 | let res = note_queries::update_links(id, &links, state.notebook.db()).await; 155 | 156 | match res { 157 | Ok(()) => StatusCode::OK, 158 | Err(err) => { 159 | pretty_error!("Error encountered when updating note links : {err}"); 160 | StatusCode::INTERNAL_SERVER_ERROR 161 | } 162 | } 163 | } 164 | 165 | pub(crate) async fn list_tags( 166 | State(state): State, 167 | Json(id): Json, 168 | ) -> FailibleJsonResult> { 169 | let res = note_queries::list_tags(id, state.notebook.db()).await; 170 | 171 | match res { 172 | Ok(res) => Ok((StatusCode::OK, Json::from(res))), 173 | Err(err) => { 174 | pretty_error!("Error encountered while listing note's tags : {err}"); 175 | Err(StatusCode::INTERNAL_SERVER_ERROR) 176 | } 177 | } 178 | } 179 | 180 | pub(crate) async fn validate_new_tag( 181 | State(state): State, 182 | Json(ValidateNewTagParam { id, tag_id }): Json, 183 | ) -> FailibleJsonResult> { 184 | let res = note_queries::validate_new_tag(id, tag_id, state.notebook.db()).await; 185 | 186 | match res { 187 | Ok(res) => Ok((StatusCode::OK, Json::from(res.map(|err| Error::new(&*err))))), 188 | Err(err) => { 189 | pretty_error!("Error encountered during tag validation : {err}"); 190 | Err(StatusCode::INTERNAL_SERVER_ERROR) 191 | } 192 | } 193 | } 194 | 195 | pub(crate) async fn add_tag( 196 | State(state): State, 197 | Json(AddTagParam { id, tag_id }): Json, 198 | ) -> FailibleJsonResult> { 199 | if !state.permissions.writable() { 200 | return Err(StatusCode::UNAUTHORIZED); 201 | } 202 | 203 | let res = note_queries::add_tag(id, tag_id, state.notebook.db()).await; 204 | 205 | match res { 206 | Ok(()) => Ok((StatusCode::OK, Json::from(None))), 207 | Err(err) => { 208 | if err.is::() || err.is::() { 209 | Ok(( 210 | StatusCode::NOT_ACCEPTABLE, 211 | Json::from(Some(Error::new(&*err))), 212 | )) 213 | } else { 214 | pretty_error!("Error encountered while adding tag : {err}"); 215 | Err(StatusCode::INTERNAL_SERVER_ERROR) 216 | } 217 | } 218 | } 219 | } 220 | 221 | pub(crate) async fn remove_tag( 222 | State(state): State, 223 | Json(RemoveTagParam { id, tag_id }): Json, 224 | ) -> StatusCode { 225 | if !state.permissions.writable() { 226 | return StatusCode::UNAUTHORIZED; 227 | } 228 | 229 | let res = note_queries::remove_tag(id, tag_id, state.notebook.db()).await; 230 | 231 | match res { 232 | Ok(()) => StatusCode::OK, 233 | Err(err) => { 234 | pretty_error!("Error encountered while removing tag : {err}"); 235 | StatusCode::INTERNAL_SERVER_ERROR 236 | } 237 | } 238 | } 239 | 240 | pub(crate) async fn search_by_name( 241 | State(state): State, 242 | Json(pattern): Json, 243 | ) -> FailibleJsonResult> { 244 | let res = note_queries::search_by_name(&pattern, state.notebook.db()).await; 245 | 246 | match res { 247 | Ok(res) => Ok((StatusCode::OK, Json::from(res))), 248 | Err(err) => { 249 | pretty_error!("Error encountered when searching notes : {err}"); 250 | Err(StatusCode::INTERNAL_SERVER_ERROR) 251 | } 252 | } 253 | } 254 | 255 | pub(crate) async fn search_with_tag( 256 | State(state): State, 257 | Json(SearchWithTagParam { tag_id, pattern }): Json, 258 | ) -> FailibleJsonResult> { 259 | let res = note_queries::search_with_tag(tag_id, &pattern, state.notebook.db()).await; 260 | 261 | match res { 262 | Ok(res) => Ok((StatusCode::OK, Json::from(res))), 263 | Err(err) => { 264 | pretty_error!("Error encountered while fetching notes summaries : {err}"); 265 | Err(StatusCode::INTERNAL_SERVER_ERROR) 266 | } 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /foucault-client/src/states/notes_managing.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::Result; 4 | use log::{info, warn}; 5 | 6 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 7 | use ratatui::{ 8 | layout::Rect, 9 | prelude::{Constraint, Direction, Layout, Margin}, 10 | style::{Color, Modifier, Style}, 11 | text::{Line, Span}, 12 | widgets::{ 13 | Block, BorderType, Borders, Clear, List, ListState, Padding, Scrollbar, 14 | ScrollbarOrientation, ScrollbarState, 15 | }, 16 | Frame, 17 | }; 18 | 19 | use crate::{ 20 | helpers::{create_bottom_help_bar, EditableText}, 21 | note::{Note, NoteSummary}, 22 | states::{ 23 | note_creation::NoteCreationStateData, note_deletion::NoteDeletionStateData, 24 | note_viewing::NoteViewingStateData, State, 25 | }, 26 | NotebookAPI, 27 | }; 28 | 29 | use foucault_core::note_repr::NoteError; 30 | 31 | #[derive(Clone)] 32 | pub struct NotesManagingStateData { 33 | pub pattern: EditableText, 34 | selected: usize, 35 | notes: Arc<[NoteSummary]>, 36 | help_display: bool, 37 | } 38 | 39 | impl NotesManagingStateData { 40 | pub async fn from_pattern(pattern: EditableText, notebook: &NotebookAPI) -> Result { 41 | Ok(NotesManagingStateData { 42 | notes: NoteSummary::search_by_name(&pattern, notebook) 43 | .await? 44 | .into(), 45 | selected: 0, 46 | help_display: false, 47 | pattern, 48 | }) 49 | } 50 | 51 | pub async fn empty(notebook: &NotebookAPI) -> Result { 52 | Self::from_pattern(EditableText::new(String::new()), notebook).await 53 | } 54 | 55 | fn selected(&self) -> &NoteSummary { 56 | &self.notes[self.selected] 57 | } 58 | } 59 | 60 | pub async fn run_note_managing_state( 61 | mut state_data: NotesManagingStateData, 62 | key_event: KeyEvent, 63 | notebook: &NotebookAPI, 64 | ) -> Result { 65 | Ok(match key_event.code { 66 | KeyCode::Esc => { 67 | info!("Quit the notes manager."); 68 | State::Nothing 69 | } 70 | KeyCode::Char('q') if key_event.modifiers == KeyModifiers::CONTROL => { 71 | info!("Quit foucault."); 72 | State::Exit 73 | } 74 | KeyCode::Char('h') if key_event.modifiers == KeyModifiers::CONTROL => { 75 | info!("Toogle the help bar."); 76 | state_data.help_display = !state_data.help_display; 77 | State::NotesManaging(state_data) 78 | } 79 | KeyCode::Char('c') if key_event.modifiers == KeyModifiers::CONTROL => { 80 | info!("Open the note creation prompt."); 81 | State::NoteCreation(NoteCreationStateData::from_notes_managing(state_data)) 82 | } 83 | KeyCode::Char('d') 84 | if key_event.modifiers == KeyModifiers::CONTROL && !state_data.notes.is_empty() => 85 | { 86 | info!("Open the note deletion prompt."); 87 | let selected_note = state_data.selected(); 88 | State::NoteDeletion(NoteDeletionStateData::from_notes_managing( 89 | selected_note.name().to_string(), 90 | selected_note.id(), 91 | state_data, 92 | )) 93 | } 94 | KeyCode::Enter if !state_data.notes.is_empty() => { 95 | let note_summary = state_data.selected(); 96 | info!("Open note {}.", note_summary.name()); 97 | 98 | let note = Note::load_by_id(note_summary.id(), notebook) 99 | .await? 100 | .ok_or(NoteError::DoesNotExist)?; 101 | State::NoteViewing(NoteViewingStateData::new(note, notebook).await?) 102 | } 103 | KeyCode::Backspace if key_event.modifiers == KeyModifiers::NONE => { 104 | state_data.pattern.remove_char(); 105 | state_data.notes = NoteSummary::search_by_name(&state_data.pattern, notebook) 106 | .await? 107 | .into(); 108 | state_data.selected = 0; 109 | 110 | State::NotesManaging(state_data) 111 | } 112 | KeyCode::Delete if key_event.modifiers == KeyModifiers::NONE => { 113 | state_data.pattern.del_char(); 114 | state_data.notes = NoteSummary::search_by_name(&state_data.pattern, notebook) 115 | .await? 116 | .into(); 117 | state_data.selected = 0; 118 | 119 | State::NotesManaging(state_data) 120 | } 121 | KeyCode::Char(c) if key_event.modifiers == KeyModifiers::NONE => { 122 | state_data.pattern.insert_char(c); 123 | state_data.notes = NoteSummary::search_by_name(&state_data.pattern, notebook) 124 | .await? 125 | .into(); 126 | state_data.selected = 0; 127 | 128 | State::NotesManaging(state_data) 129 | } 130 | KeyCode::Left if key_event.modifiers == KeyModifiers::NONE => { 131 | state_data.pattern.move_left(); 132 | 133 | State::NotesManaging(state_data) 134 | } 135 | KeyCode::Right if key_event.modifiers == KeyModifiers::NONE => { 136 | state_data.pattern.move_right(); 137 | 138 | State::NotesManaging(state_data) 139 | } 140 | KeyCode::Up if state_data.selected > 0 => State::NotesManaging(NotesManagingStateData { 141 | selected: state_data.selected - 1, 142 | ..state_data 143 | }), 144 | KeyCode::Down if state_data.selected < state_data.notes.len().saturating_sub(1) => { 145 | State::NotesManaging(NotesManagingStateData { 146 | selected: state_data.selected + 1, 147 | ..state_data 148 | }) 149 | } 150 | _ => State::NotesManaging(state_data), 151 | }) 152 | } 153 | 154 | pub fn draw_notes_managing_state( 155 | NotesManagingStateData { 156 | pattern, 157 | selected, 158 | notes, 159 | help_display, 160 | }: &NotesManagingStateData, 161 | notebook: &NotebookAPI, 162 | frame: &mut Frame, 163 | main_rect: Rect, 164 | ) { 165 | let vertical_layout = Layout::new( 166 | Direction::Vertical, 167 | [Constraint::Length(5), Constraint::Min(0)], 168 | ) 169 | .split(main_rect); 170 | 171 | let search_bar = pattern.build_paragraph().block( 172 | Block::new() 173 | .title("Filter") 174 | .borders(Borders::ALL) 175 | .border_type(BorderType::Rounded) 176 | .border_style(Style::new().fg(if notes.is_empty() { 177 | Color::Red 178 | } else { 179 | Color::Green 180 | })) 181 | .padding(Padding::uniform(1)), 182 | ); 183 | 184 | let list_results = List::new(notes.iter().map(|note| { 185 | let mut note_line = 186 | if let Some(pattern_start) = note.name().to_lowercase().find(&pattern.to_lowercase()) { 187 | let pattern_end = pattern_start + pattern.len(); 188 | vec![ 189 | Span::raw(¬e.name()[..pattern_start]) 190 | .style(Style::new().add_modifier(Modifier::BOLD)), 191 | Span::raw(¬e.name()[pattern_start..pattern_end]).style( 192 | Style::new() 193 | .add_modifier(Modifier::BOLD) 194 | .add_modifier(Modifier::UNDERLINED), 195 | ), 196 | Span::raw(¬e.name()[pattern_end..]) 197 | .style(Style::new().add_modifier(Modifier::BOLD)), 198 | ] 199 | } else { 200 | warn!( 201 | "The search pattern '{}' did not match on note {}", 202 | &**pattern, 203 | note.name() 204 | ); 205 | vec![Span::raw(note.name())] 206 | }; 207 | 208 | note_line.push(Span::raw(" ")); 209 | 210 | for tag in ¬e.tags() { 211 | note_line.push( 212 | Span::raw(tag.name().to_string()) 213 | .style(Style::new().bg(Color::from_u32(tag.color()))), 214 | ); 215 | note_line.push(Span::raw(", ")); 216 | } 217 | if !note.tags().is_empty() { 218 | note_line.pop(); 219 | } 220 | 221 | Line::from(note_line) 222 | })) 223 | .highlight_symbol(">> ") 224 | .highlight_style(Style::new().bg(Color::White).fg(Color::Black)) 225 | .block( 226 | Block::new() 227 | .title("Results") 228 | .borders(Borders::ALL) 229 | .border_type(BorderType::Rounded) 230 | .border_style(Style::new().fg(Color::Yellow)) 231 | .padding(Padding::uniform(1)), 232 | ); 233 | 234 | let notes_scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) 235 | .begin_symbol(Some("↑")) 236 | .end_symbol(Some("↓")); 237 | 238 | frame.render_widget(search_bar, vertical_layout[0]); 239 | frame.render_stateful_widget( 240 | list_results, 241 | vertical_layout[1], 242 | &mut ListState::with_selected(ListState::default(), Some(*selected)), 243 | ); 244 | frame.render_stateful_widget( 245 | notes_scrollbar, 246 | vertical_layout[1].inner(Margin::new(0, 1)), 247 | &mut ScrollbarState::new(notes.len()).position(*selected), 248 | ); 249 | 250 | if *help_display { 251 | let writing_op_color = if notebook.permissions.writable() { 252 | Color::Blue 253 | } else { 254 | Color::Red 255 | }; 256 | let (commands, commands_area) = create_bottom_help_bar( 257 | &[ 258 | ("Ctrl+c", writing_op_color, "Create note"), 259 | ("Ctrl+d", writing_op_color, "Delete note"), 260 | ("⏎", Color::Blue, "Open note"), 261 | ], 262 | 3, 263 | main_rect, 264 | ); 265 | 266 | frame.render_widget(Clear, commands_area); 267 | frame.render_widget(commands, commands_area); 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /foucault-client/src/helpers.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | 3 | use ratatui::{ 4 | prelude::{Alignment, Constraint, Direction, Layout, Rect}, 5 | style::{Color, Modifier, Style, Stylize}, 6 | text::{Line, Span}, 7 | widgets::{Block, BorderType, Borders, Cell, Clear, Padding, Paragraph, Row, Table}, 8 | Frame, 9 | }; 10 | use unicode_segmentation::UnicodeSegmentation; 11 | 12 | pub fn create_popup(proportion: (Constraint, Constraint), rect: Rect) -> Rect { 13 | let vertical = Layout::new( 14 | Direction::Vertical, 15 | match proportion.1 { 16 | Constraint::Percentage(percent) => [ 17 | Constraint::Percentage((100u16.saturating_sub(percent)) / 2), 18 | Constraint::Percentage(percent), 19 | Constraint::Percentage((100u16.saturating_sub(percent)) / 2), 20 | ], 21 | Constraint::Length(length) => [ 22 | Constraint::Length((rect.height.saturating_sub(length)) / 2), 23 | Constraint::Length(length), 24 | Constraint::Length((rect.height.saturating_sub(length)) / 2), 25 | ], 26 | _ => unimplemented!(), 27 | }, 28 | ) 29 | .split(rect); 30 | let horizontal = Layout::new( 31 | Direction::Horizontal, 32 | match proportion.0 { 33 | Constraint::Percentage(percent) => [ 34 | Constraint::Percentage((100u16.saturating_sub(percent)) / 2), 35 | Constraint::Percentage(percent), 36 | Constraint::Percentage((100u16.saturating_sub(percent)) / 2), 37 | ], 38 | Constraint::Length(length) => [ 39 | Constraint::Length((rect.width.saturating_sub(length)) / 2), 40 | Constraint::Length(length), 41 | Constraint::Length((rect.width.saturating_sub(length)) / 2), 42 | ], 43 | _ => unimplemented!(), 44 | }, 45 | ) 46 | .split(vertical[1]); 47 | horizontal[1] 48 | } 49 | 50 | pub fn create_bottom_help_bar<'a>( 51 | help: &[(&'a str, Color, &'a str)], 52 | max_by_row: usize, 53 | rect: Rect, 54 | ) -> (Table<'a>, Rect) { 55 | let rows: Vec<_> = help 56 | .chunks(max_by_row) 57 | .map(|infos| { 58 | Row::new(infos.iter().flat_map(|(key, color, def)| { 59 | [ 60 | Cell::from(*key).style(Style::new().bg(*color).add_modifier(Modifier::BOLD)), 61 | Cell::from(*def).style(Style::new().bg(Color::Black)), 62 | ] 63 | })) 64 | }) 65 | .collect(); 66 | let row_count = rows.len(); 67 | let table = Table::new( 68 | rows, 69 | [Constraint::Fill(1), Constraint::Fill(2)] 70 | .into_iter() 71 | .cycle() 72 | .take((help.len() * 2).min(max_by_row * 2)), 73 | ) 74 | .block( 75 | Block::new() 76 | .padding(Padding::horizontal(1)) 77 | .borders(Borders::all()) 78 | .border_type(BorderType::Double) 79 | .border_style(Style::new().fg(Color::White)), 80 | ); 81 | 82 | let vertical = Layout::new( 83 | Direction::Vertical, 84 | [ 85 | Constraint::Percentage(100), 86 | Constraint::Min(u16::try_from(row_count).unwrap() + 2), 87 | ], 88 | ) 89 | .split(rect); 90 | (table, vertical[1]) 91 | } 92 | 93 | pub fn create_left_help_bar<'a>( 94 | help: &[(&'a str, Color, &'a str)], 95 | rect: Rect, 96 | ) -> (Table<'a>, Rect) { 97 | let rows: Vec<_> = help 98 | .iter() 99 | .map(|(key, color, def)| { 100 | Row::new([ 101 | Cell::from(*key).style(Style::new().bg(*color).add_modifier(Modifier::BOLD)), 102 | Cell::from(*def).style(Style::new().bg(Color::Black)), 103 | ]) 104 | }) 105 | .collect(); 106 | let table = Table::new(rows, [Constraint::Fill(1), Constraint::Fill(2)]).block( 107 | Block::new() 108 | .padding(Padding::horizontal(1)) 109 | .borders(Borders::all()) 110 | .border_type(BorderType::Double) 111 | .border_style(Style::new().fg(Color::White)), 112 | ); 113 | 114 | let horrizontal = Layout::new( 115 | Direction::Horizontal, 116 | [Constraint::Percentage(40), Constraint::Min(0)], 117 | ) 118 | .split(rect); 119 | (table, horrizontal[0]) 120 | } 121 | 122 | pub fn draw_yes_no_prompt(frame: &mut Frame, choice: bool, title: &str, main_rect: Rect) { 123 | let popup_area = create_popup((Constraint::Length(50), Constraint::Length(5)), main_rect); 124 | let block = Block::new() 125 | .title(title) 126 | .borders(Borders::ALL) 127 | .border_type(BorderType::Rounded) 128 | .border_style(Style::new().fg(Color::Blue)); 129 | 130 | let layout = Layout::new( 131 | Direction::Horizontal, 132 | [Constraint::Percentage(50), Constraint::Percentage(50)], 133 | ) 134 | .split(block.inner(popup_area)); 135 | 136 | let yes = Paragraph::new(Line::from(vec![if choice { 137 | Span::raw("Yes").add_modifier(Modifier::UNDERLINED) 138 | } else { 139 | Span::raw("Yes") 140 | }])) 141 | .style(Style::new().fg(Color::Green)) 142 | .alignment(Alignment::Center) 143 | .block( 144 | Block::new() 145 | .borders(Borders::ALL) 146 | .border_type(BorderType::Plain) 147 | .border_style(Style::new().fg(Color::Green)), 148 | ); 149 | let no = Paragraph::new(Line::from(vec![if choice { 150 | Span::raw("No") 151 | } else { 152 | Span::raw("No").add_modifier(Modifier::UNDERLINED) 153 | }])) 154 | .style(Style::new().fg(Color::Red)) 155 | .alignment(Alignment::Center) 156 | .block( 157 | Block::new() 158 | .borders(Borders::ALL) 159 | .border_type(BorderType::Plain) 160 | .border_style(Style::new().fg(Color::Red)), 161 | ); 162 | 163 | frame.render_widget(Clear, popup_area); 164 | frame.render_widget(yes, layout[0]); 165 | frame.render_widget(no, layout[1]); 166 | frame.render_widget(block, popup_area); 167 | } 168 | 169 | pub fn draw_text_prompt( 170 | frame: &mut ratatui::Frame<'_>, 171 | title: &str, 172 | text: &EditableText, 173 | valid: bool, 174 | main_rect: ratatui::prelude::Rect, 175 | ) { 176 | let popup_area = create_popup((Constraint::Length(30), Constraint::Length(5)), main_rect); 177 | 178 | let new_note_entry = text.build_paragraph().block( 179 | Block::new() 180 | .title(title) 181 | .borders(Borders::ALL) 182 | .border_type(BorderType::Rounded) 183 | .border_style(Style::new().fg(if valid { Color::Green } else { Color::Red })) 184 | .padding(Padding::uniform(1)), 185 | ); 186 | 187 | frame.render_widget(Clear, popup_area); 188 | frame.render_widget(new_note_entry, popup_area); 189 | } 190 | 191 | #[derive(Clone)] 192 | pub struct EditableText { 193 | text: String, 194 | cursor: usize, 195 | } 196 | 197 | impl EditableText { 198 | pub fn new(text: String) -> Self { 199 | Self { 200 | cursor: text.graphemes(true).count(), 201 | text, 202 | } 203 | } 204 | 205 | fn len(&self) -> usize { 206 | self.text.graphemes(true).count() 207 | } 208 | 209 | pub fn consume(self) -> String { 210 | self.text 211 | } 212 | 213 | pub fn move_left(&mut self) { 214 | self.cursor = self.cursor.saturating_sub(1); 215 | } 216 | 217 | pub fn move_right(&mut self) { 218 | if self.cursor < self.len() { 219 | self.cursor += 1; 220 | } 221 | } 222 | 223 | pub fn insert_char(&mut self, c: char) { 224 | if self.cursor == 0 { 225 | self.text.insert(0, c); 226 | } else { 227 | self.text.insert( 228 | self.text 229 | .grapheme_indices(true) 230 | .map(|(i, g)| i + g.len()) 231 | .nth(self.cursor - 1) 232 | .unwrap(), 233 | c, 234 | ); 235 | } 236 | self.move_right(); 237 | } 238 | 239 | pub fn remove_char(&mut self) { 240 | if !self.text.is_empty() && self.cursor > 0 { 241 | let (start, end) = self 242 | .text 243 | .grapheme_indices(true) 244 | .map(|(i, g)| (i, i + g.len())) 245 | .nth(self.cursor - 1) 246 | .unwrap(); 247 | self.text.drain(start..end); 248 | self.move_left(); 249 | } 250 | } 251 | 252 | pub fn del_char(&mut self) { 253 | if self.cursor < self.text.len() { 254 | let (start, end) = self 255 | .text 256 | .grapheme_indices(true) 257 | .map(|(i, g)| (i, i + g.len())) 258 | .nth(self.cursor) 259 | .unwrap(); 260 | self.text.drain(start..end); 261 | } 262 | } 263 | 264 | pub fn build_paragraph(&self) -> Paragraph { 265 | let graphemes: Vec<&str> = 266 | UnicodeSegmentation::graphemes(self.text.as_str(), true).collect(); 267 | 268 | let before_cursor = Span::raw(graphemes[..self.cursor].concat()) 269 | .style(Style::new().add_modifier(Modifier::UNDERLINED)); 270 | let cursor = if self.cursor == graphemes.len() { 271 | Span::raw(" ").style(Style::new().bg(Color::Black)) 272 | } else { 273 | Span::raw(graphemes[self.cursor..=self.cursor].concat()).style( 274 | Style::new() 275 | .bg(Color::Black) 276 | .add_modifier(Modifier::UNDERLINED), 277 | ) 278 | }; 279 | let after_cursor = if self.cursor == graphemes.len() { 280 | Span::raw("") 281 | } else { 282 | Span::raw(graphemes[(self.cursor + 1)..].concat()) 283 | .style(Style::new().add_modifier(Modifier::UNDERLINED)) 284 | }; 285 | Paragraph::new(Line::from(vec![before_cursor, cursor, after_cursor])) 286 | } 287 | } 288 | 289 | impl Deref for EditableText { 290 | type Target = str; 291 | fn deref(&self) -> &Self::Target { 292 | &self.text 293 | } 294 | } 295 | 296 | pub trait Capitalize<'a> { 297 | fn capitalize(&'a self) -> String; 298 | } 299 | 300 | impl<'a, T: 'a + ?Sized> Capitalize<'a> for T 301 | where 302 | &'a T: Into<&'a str>, 303 | { 304 | fn capitalize(&'a self) -> String { 305 | let inner_str: &'a str = self.into(); 306 | if let Some(b) = inner_str.chars().nth(0) { 307 | let mut formated_string = b.to_uppercase().to_string(); 308 | formated_string.extend(inner_str.chars().skip(1)); 309 | formated_string 310 | } else { 311 | String::new() 312 | } 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /wix/main.wxs: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 46 | 47 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 69 | 70 | 80 | 81 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 106 | 111 | 112 | 113 | 114 | 122 | 123 | 124 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 145 | 146 | 150 | 151 | 152 | 153 | 154 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 191 | 1 192 | 1 193 | 194 | 195 | 196 | 197 | 202 | 203 | 204 | 205 | 213 | 214 | 215 | 216 | 224 | 225 | 226 | 227 | 228 | 229 | -------------------------------------------------------------------------------- /foucault-client/src/note.rs: -------------------------------------------------------------------------------- 1 | use std::{path::Path, sync::Arc}; 2 | 3 | use tokio::fs; 4 | 5 | use anyhow::Result; 6 | use serde_error::Error; 7 | 8 | use foucault_core::{ 9 | api, 10 | note_repr::{self, NoteError}, 11 | tag_repr, 12 | }; 13 | 14 | use crate::{links::Link, response_error::TryResponseCode, tag::Tag, ApiError, NotebookAPI}; 15 | 16 | #[derive(Debug, Clone)] 17 | pub struct Note { 18 | inner: note_repr::Note, 19 | } 20 | 21 | impl From for Note { 22 | fn from(inner: note_repr::Note) -> Self { 23 | Self { inner } 24 | } 25 | } 26 | 27 | #[derive(Debug, Clone)] 28 | pub struct NoteSummary { 29 | inner: note_repr::NoteSummary, 30 | } 31 | 32 | impl From for NoteSummary { 33 | fn from(inner: note_repr::NoteSummary) -> Self { 34 | Self { inner } 35 | } 36 | } 37 | 38 | impl Note { 39 | pub async fn new(name: String, content: String, notebook: &NotebookAPI) -> Result { 40 | let res = notebook 41 | .client 42 | .post(notebook.build_url("/note/create")) 43 | .json(&api::note::CreateParam { 44 | name: name.clone(), 45 | content: content.clone(), 46 | }) 47 | .send() 48 | .await 49 | .map_err(ApiError::UnableToContactRemoteNotebook)? 50 | .try_response_code()? 51 | .json::>() 52 | .await 53 | .map_err(ApiError::UnableToParseResponse)?; 54 | 55 | match res { 56 | Ok(id) => Ok(Self { 57 | inner: note_repr::Note { 58 | id, 59 | name: Arc::from(name), 60 | content: Arc::from(content), 61 | }, 62 | }), 63 | Err(err) => panic!("The note name was invalid : {err}"), 64 | } 65 | } 66 | 67 | pub async fn validate_name(name: &str, notebook: &NotebookAPI) -> Result { 68 | let res = notebook 69 | .client 70 | .get(notebook.build_url("/note/validate/name")) 71 | .json(name) 72 | .send() 73 | .await 74 | .map_err(ApiError::UnableToContactRemoteNotebook)? 75 | .try_response_code()? 76 | .json::>() 77 | .await 78 | .map_err(ApiError::UnableToParseResponse)?; 79 | 80 | Ok(res.is_none()) 81 | } 82 | 83 | pub async fn load_by_id(id: i64, notebook: &NotebookAPI) -> Result> { 84 | let res = notebook 85 | .client 86 | .get(notebook.build_url("/note/load/id")) 87 | .json(&id) 88 | .send() 89 | .await 90 | .map_err(ApiError::UnableToContactRemoteNotebook)? 91 | .try_response_code()? 92 | .json::>() 93 | .await 94 | .map_err(ApiError::UnableToParseResponse)?; 95 | 96 | Ok(res.map(Self::from)) 97 | } 98 | 99 | pub async fn load_by_name(name: &str, notebook: &NotebookAPI) -> Result> { 100 | let res = notebook 101 | .client 102 | .get(notebook.build_url("/note/load/name")) 103 | .json(name) 104 | .send() 105 | .await 106 | .map_err(ApiError::UnableToContactRemoteNotebook)? 107 | .try_response_code()? 108 | .json::>() 109 | .await 110 | .map_err(ApiError::UnableToParseResponse)?; 111 | 112 | Ok(res.map(Self::from)) 113 | } 114 | 115 | pub fn id(&self) -> i64 { 116 | self.inner.id 117 | } 118 | pub fn name(&self) -> &str { 119 | &self.inner.name 120 | } 121 | pub fn content(&self) -> &str { 122 | &self.inner.content 123 | } 124 | 125 | pub async fn tags(&self, notebook: &NotebookAPI) -> Result> { 126 | let res = notebook 127 | .client 128 | .get(notebook.build_url("/note/tag/list")) 129 | .json(&self.id()) 130 | .send() 131 | .await 132 | .map_err(ApiError::UnableToContactRemoteNotebook)? 133 | .try_response_code()? 134 | .json::>() 135 | .await 136 | .map_err(ApiError::UnableToParseResponse)?; 137 | 138 | Ok(res.into_iter().map(Tag::from).collect()) 139 | } 140 | 141 | pub async fn rename(&mut self, name: String, notebook: &NotebookAPI) -> Result<()> { 142 | let res = notebook 143 | .client 144 | .patch(notebook.build_url("/note/update/name")) 145 | .json(&api::note::RenameParam { 146 | id: self.id(), 147 | name: name.clone(), 148 | }) 149 | .send() 150 | .await 151 | .map_err(ApiError::UnableToContactRemoteNotebook)? 152 | .try_response_code()? 153 | .json::>() 154 | .await 155 | .map_err(ApiError::UnableToParseResponse)?; 156 | 157 | if let Some(err) = res { 158 | panic!("The note name is invalid : {err}"); 159 | } 160 | 161 | self.inner.name = Arc::from(name); 162 | Ok(()) 163 | } 164 | 165 | pub async fn delete(id: i64, notebook: &NotebookAPI) -> Result<()> { 166 | notebook 167 | .client 168 | .delete(notebook.build_url("/note/delete")) 169 | .json(&id) 170 | .send() 171 | .await 172 | .map_err(ApiError::UnableToContactRemoteNotebook)? 173 | .try_response_code()?; 174 | Ok(()) 175 | } 176 | 177 | pub async fn export_content(&self, file: &Path) -> Result<()> { 178 | fs::write(file, self.inner.content.as_bytes()) 179 | .await 180 | .map_err(anyhow::Error::from) 181 | } 182 | 183 | pub async fn import_content(&mut self, file: &Path, notebook: &NotebookAPI) -> Result<()> { 184 | let new_content = String::from_utf8(fs::read(file).await?)?; 185 | 186 | notebook 187 | .client 188 | .patch(notebook.build_url("/note/update/content")) 189 | .json(&api::note::UpdateContentParam { 190 | id: self.id(), 191 | content: new_content.clone(), 192 | }) 193 | .send() 194 | .await 195 | .map_err(ApiError::UnableToContactRemoteNotebook)? 196 | .try_response_code()?; 197 | 198 | self.inner.content = Arc::from(new_content); 199 | Ok(()) 200 | } 201 | 202 | pub async fn update_links(&self, new_links: &[Link], notebook: &NotebookAPI) -> Result<()> { 203 | notebook 204 | .client 205 | .patch(notebook.build_url("/note/update/links")) 206 | .json(&api::note::UpdateLinksParam { 207 | id: self.id(), 208 | links: new_links 209 | .iter() 210 | .map(|link| link.get_inner().clone()) 211 | .collect(), 212 | }) 213 | .send() 214 | .await 215 | .map_err(ApiError::UnableToContactRemoteNotebook)? 216 | .try_response_code()?; 217 | 218 | Ok(()) 219 | } 220 | 221 | pub async fn validate_tag(&self, tag_id: i64, notebook: &NotebookAPI) -> Result { 222 | let res = notebook 223 | .client 224 | .get(notebook.build_url("/note/validate/tag")) 225 | .json(&api::note::ValidateNewTagParam { 226 | id: self.id(), 227 | tag_id, 228 | }) 229 | .send() 230 | .await 231 | .map_err(ApiError::UnableToContactRemoteNotebook)? 232 | .try_response_code()? 233 | .json::>() 234 | .await 235 | .map_err(ApiError::UnableToParseResponse)?; 236 | 237 | Ok(res.is_none()) 238 | } 239 | 240 | pub async fn add_tag(&self, tag_id: i64, notebook: &NotebookAPI) -> Result<()> { 241 | let res = notebook 242 | .client 243 | .post(notebook.build_url("/note/tag/add")) 244 | .json(&api::note::AddTagParam { 245 | id: self.id(), 246 | tag_id, 247 | }) 248 | .send() 249 | .await 250 | .map_err(ApiError::UnableToContactRemoteNotebook)? 251 | .try_response_code()? 252 | .json::>() 253 | .await 254 | .map_err(ApiError::UnableToParseResponse)?; 255 | 256 | if let Some(err) = res { 257 | panic!("Failled to add tag : {err}"); 258 | } 259 | 260 | Ok(()) 261 | } 262 | 263 | pub async fn remove_tag(&mut self, tag_id: i64, notebook: &NotebookAPI) -> Result<()> { 264 | notebook 265 | .client 266 | .delete(notebook.build_url("/note/tag/remove")) 267 | .json(&api::note::RemoveTagParam { 268 | id: self.id(), 269 | tag_id, 270 | }) 271 | .send() 272 | .await 273 | .map_err(ApiError::UnableToContactRemoteNotebook)? 274 | .try_response_code()?; 275 | 276 | Ok(()) 277 | } 278 | } 279 | 280 | impl NoteSummary { 281 | pub fn id(&self) -> i64 { 282 | self.inner.id 283 | } 284 | pub fn name(&self) -> &str { 285 | &self.inner.name 286 | } 287 | pub fn tags(&self) -> Vec { 288 | self.inner.tags.iter().cloned().map(Tag::from).collect() 289 | } 290 | 291 | pub async fn search_by_name(pattern: &str, notebook: &NotebookAPI) -> Result> { 292 | let res = notebook 293 | .client 294 | .get(notebook.build_url("/note/search/name")) 295 | .json(pattern) 296 | .send() 297 | .await 298 | .map_err(ApiError::UnableToContactRemoteNotebook)? 299 | .try_response_code()? 300 | .json::>() 301 | .await 302 | .map_err(ApiError::UnableToParseResponse)?; 303 | 304 | Ok(res.into_iter().map(Self::from).collect()) 305 | } 306 | 307 | pub async fn search_with_tag( 308 | tag_id: i64, 309 | pattern: &str, 310 | notebook: &NotebookAPI, 311 | ) -> Result> { 312 | let res = notebook 313 | .client 314 | .get(notebook.build_url("/note/search/tag")) 315 | .json(&api::note::SearchWithTagParam { 316 | tag_id, 317 | pattern: pattern.to_string(), 318 | }) 319 | .send() 320 | .await 321 | .map_err(ApiError::UnableToContactRemoteNotebook)? 322 | .try_response_code()? 323 | .json::>() 324 | .await 325 | .map_err(ApiError::UnableToParseResponse)?; 326 | 327 | Ok(res.into_iter().map(Self::from).collect()) 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by cargo-dist: https://opensource.axo.dev/cargo-dist/ 2 | # 3 | # Copyright 2022-2024, axodotdev 4 | # SPDX-License-Identifier: MIT or Apache-2.0 5 | # 6 | # CI that: 7 | # 8 | # * checks for a Git Tag that looks like a release 9 | # * builds artifacts with cargo-dist (archives, installers, hashes) 10 | # * uploads those artifacts to temporary workflow zip 11 | # * on success, uploads the artifacts to a GitHub Release 12 | # 13 | # Note that the GitHub Release will be created with a generated 14 | # title/body based on your changelogs. 15 | 16 | name: Release 17 | permissions: 18 | "contents": "write" 19 | 20 | # This task will run whenever you push a git tag that looks like a version 21 | # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. 22 | # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where 23 | # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION 24 | # must be a Cargo-style SemVer Version (must have at least major.minor.patch). 25 | # 26 | # If PACKAGE_NAME is specified, then the announcement will be for that 27 | # package (erroring out if it doesn't have the given version or isn't cargo-dist-able). 28 | # 29 | # If PACKAGE_NAME isn't specified, then the announcement will be for all 30 | # (cargo-dist-able) packages in the workspace with that version (this mode is 31 | # intended for workspaces with only one dist-able package, or with all dist-able 32 | # packages versioned/released in lockstep). 33 | # 34 | # If you push multiple tags at once, separate instances of this workflow will 35 | # spin up, creating an independent announcement for each one. However, GitHub 36 | # will hard limit this to 3 tags per commit, as it will assume more tags is a 37 | # mistake. 38 | # 39 | # If there's a prerelease-style suffix to the version, then the release(s) 40 | # will be marked as a prerelease. 41 | on: 42 | pull_request: 43 | push: 44 | tags: 45 | - '**[0-9]+.[0-9]+.[0-9]+*' 46 | 47 | jobs: 48 | # Run 'cargo dist plan' (or host) to determine what tasks we need to do 49 | plan: 50 | runs-on: "ubuntu-20.04" 51 | outputs: 52 | val: ${{ steps.plan.outputs.manifest }} 53 | tag: ${{ !github.event.pull_request && github.ref_name || '' }} 54 | tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} 55 | publishing: ${{ !github.event.pull_request }} 56 | env: 57 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | steps: 59 | - uses: actions/checkout@v4 60 | with: 61 | submodules: recursive 62 | - name: Install cargo-dist 63 | # we specify bash to get pipefail; it guards against the `curl` command 64 | # failing. otherwise `sh` won't catch that `curl` returned non-0 65 | shell: bash 66 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.21.1/cargo-dist-installer.sh | sh" 67 | - name: Cache cargo-dist 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: cargo-dist-cache 71 | path: ~/.cargo/bin/cargo-dist 72 | # sure would be cool if github gave us proper conditionals... 73 | # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible 74 | # functionality based on whether this is a pull_request, and whether it's from a fork. 75 | # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* 76 | # but also really annoying to build CI around when it needs secrets to work right.) 77 | - id: plan 78 | run: | 79 | cargo dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json 80 | echo "cargo dist ran successfully" 81 | cat plan-dist-manifest.json 82 | echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" 83 | - name: "Upload dist-manifest.json" 84 | uses: actions/upload-artifact@v4 85 | with: 86 | name: artifacts-plan-dist-manifest 87 | path: plan-dist-manifest.json 88 | 89 | # Build and packages all the platform-specific things 90 | build-local-artifacts: 91 | name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) 92 | # Let the initial task tell us to not run (currently very blunt) 93 | needs: 94 | - plan 95 | if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} 96 | strategy: 97 | fail-fast: false 98 | # Target platforms/runners are computed by cargo-dist in create-release. 99 | # Each member of the matrix has the following arguments: 100 | # 101 | # - runner: the github runner 102 | # - dist-args: cli flags to pass to cargo dist 103 | # - install-dist: expression to run to install cargo-dist on the runner 104 | # 105 | # Typically there will be: 106 | # - 1 "global" task that builds universal installers 107 | # - N "local" tasks that build each platform's binaries and platform-specific installers 108 | matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} 109 | runs-on: ${{ matrix.runner }} 110 | env: 111 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 112 | BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json 113 | steps: 114 | - name: enable windows longpaths 115 | run: | 116 | git config --global core.longpaths true 117 | - uses: actions/checkout@v4 118 | with: 119 | submodules: recursive 120 | - name: null 121 | uses: "extractions/setup-just@v2" 122 | - name: "Install SQLx-cli" 123 | run: "cargo install sqlx-cli --no-default-features --features sqlite" 124 | - name: "Prepare queries" 125 | run: "just prepare-queries" 126 | - name: Install cargo-dist 127 | run: ${{ matrix.install_dist }} 128 | # Get the dist-manifest 129 | - name: Fetch local artifacts 130 | uses: actions/download-artifact@v4 131 | with: 132 | pattern: artifacts-* 133 | path: target/distrib/ 134 | merge-multiple: true 135 | - name: Install dependencies 136 | run: | 137 | ${{ matrix.packages_install }} 138 | - name: Build artifacts 139 | run: | 140 | # Actually do builds and make zips and whatnot 141 | cargo dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json 142 | echo "cargo dist ran successfully" 143 | - id: cargo-dist 144 | name: Post-build 145 | # We force bash here just because github makes it really hard to get values up 146 | # to "real" actions without writing to env-vars, and writing to env-vars has 147 | # inconsistent syntax between shell and powershell. 148 | shell: bash 149 | run: | 150 | # Parse out what we just built and upload it to scratch storage 151 | echo "paths<> "$GITHUB_OUTPUT" 152 | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 153 | echo "EOF" >> "$GITHUB_OUTPUT" 154 | 155 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 156 | - name: "Upload artifacts" 157 | uses: actions/upload-artifact@v4 158 | with: 159 | name: artifacts-build-local-${{ join(matrix.targets, '_') }} 160 | path: | 161 | ${{ steps.cargo-dist.outputs.paths }} 162 | ${{ env.BUILD_MANIFEST_NAME }} 163 | 164 | # Build and package all the platform-agnostic(ish) things 165 | build-global-artifacts: 166 | needs: 167 | - plan 168 | - build-local-artifacts 169 | runs-on: "ubuntu-20.04" 170 | env: 171 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 172 | BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json 173 | steps: 174 | - uses: actions/checkout@v4 175 | with: 176 | submodules: recursive 177 | - name: Install cached cargo-dist 178 | uses: actions/download-artifact@v4 179 | with: 180 | name: cargo-dist-cache 181 | path: ~/.cargo/bin/ 182 | - run: chmod +x ~/.cargo/bin/cargo-dist 183 | # Get all the local artifacts for the global tasks to use (for e.g. checksums) 184 | - name: Fetch local artifacts 185 | uses: actions/download-artifact@v4 186 | with: 187 | pattern: artifacts-* 188 | path: target/distrib/ 189 | merge-multiple: true 190 | - id: cargo-dist 191 | shell: bash 192 | run: | 193 | cargo dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json 194 | echo "cargo dist ran successfully" 195 | 196 | # Parse out what we just built and upload it to scratch storage 197 | echo "paths<> "$GITHUB_OUTPUT" 198 | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 199 | echo "EOF" >> "$GITHUB_OUTPUT" 200 | 201 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 202 | - name: "Upload artifacts" 203 | uses: actions/upload-artifact@v4 204 | with: 205 | name: artifacts-build-global 206 | path: | 207 | ${{ steps.cargo-dist.outputs.paths }} 208 | ${{ env.BUILD_MANIFEST_NAME }} 209 | # Determines if we should publish/announce 210 | host: 211 | needs: 212 | - plan 213 | - build-local-artifacts 214 | - build-global-artifacts 215 | # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) 216 | if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} 217 | env: 218 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 219 | runs-on: "ubuntu-20.04" 220 | outputs: 221 | val: ${{ steps.host.outputs.manifest }} 222 | steps: 223 | - uses: actions/checkout@v4 224 | with: 225 | submodules: recursive 226 | - name: Install cached cargo-dist 227 | uses: actions/download-artifact@v4 228 | with: 229 | name: cargo-dist-cache 230 | path: ~/.cargo/bin/ 231 | - run: chmod +x ~/.cargo/bin/cargo-dist 232 | # Fetch artifacts from scratch-storage 233 | - name: Fetch artifacts 234 | uses: actions/download-artifact@v4 235 | with: 236 | pattern: artifacts-* 237 | path: target/distrib/ 238 | merge-multiple: true 239 | - id: host 240 | shell: bash 241 | run: | 242 | cargo dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json 243 | echo "artifacts uploaded and released successfully" 244 | cat dist-manifest.json 245 | echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" 246 | - name: "Upload dist-manifest.json" 247 | uses: actions/upload-artifact@v4 248 | with: 249 | # Overwrite the previous copy 250 | name: artifacts-dist-manifest 251 | path: dist-manifest.json 252 | # Create a GitHub Release while uploading all files to it 253 | - name: "Download GitHub Artifacts" 254 | uses: actions/download-artifact@v4 255 | with: 256 | pattern: artifacts-* 257 | path: artifacts 258 | merge-multiple: true 259 | - name: Cleanup 260 | run: | 261 | # Remove the granular manifests 262 | rm -f artifacts/*-dist-manifest.json 263 | - name: Create GitHub Release 264 | env: 265 | PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" 266 | ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" 267 | ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" 268 | RELEASE_COMMIT: "${{ github.sha }}" 269 | run: | 270 | # Write and read notes from a file to avoid quoting breaking things 271 | echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt 272 | 273 | gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* 274 | 275 | announce: 276 | needs: 277 | - plan 278 | - host 279 | # use "always() && ..." to allow us to wait for all publish jobs while 280 | # still allowing individual publish jobs to skip themselves (for prereleases). 281 | # "host" however must run to completion, no skipping allowed! 282 | if: ${{ always() && needs.host.result == 'success' }} 283 | runs-on: "ubuntu-20.04" 284 | env: 285 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 286 | steps: 287 | - uses: actions/checkout@v4 288 | with: 289 | submodules: recursive 290 | -------------------------------------------------------------------------------- /foucault-client/src/markdown/elements.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use markdown::mdast; 4 | 5 | use ratatui::{ 6 | style::{Color, Modifier, Style, Stylize}, 7 | text::{Line, Span}, 8 | widgets::Paragraph, 9 | }; 10 | 11 | use unicode_segmentation::UnicodeSegmentation; 12 | 13 | use crate::markdown::{ 14 | BLOCKQUOTE, BLOCKQUOTE_ALIGNEMENT, CROSS_REF, HEADER_ALIGNEMENT, HEADER_COLOR, HEADER_MODIFIER, 15 | HYPERLINK, ITALIC, RICH_TEXT_COLOR, STRONG, TEXT, 16 | }; 17 | 18 | const TEXT_STYLE: Style = Style::new().fg(RICH_TEXT_COLOR[TEXT]); 19 | 20 | const ITALIC_STYLE: Style = Style::new() 21 | .add_modifier(Modifier::UNDERLINED) 22 | .fg(RICH_TEXT_COLOR[ITALIC]); 23 | 24 | const STRONG_STYLE: Style = Style::new() 25 | .add_modifier(Modifier::BOLD) 26 | .fg(RICH_TEXT_COLOR[STRONG]); 27 | 28 | const HYPER_LINK_STYLE: Style = Style::new() 29 | .add_modifier(Modifier::UNDERLINED) 30 | .fg(RICH_TEXT_COLOR[HYPERLINK]); 31 | 32 | const CROSS_REF_STYLE: Style = Style::new().fg(RICH_TEXT_COLOR[CROSS_REF]); 33 | 34 | const BLOCKQUOTE_STYLE: Style = Style::new() 35 | .fg(RICH_TEXT_COLOR[BLOCKQUOTE]) 36 | .add_modifier(Modifier::ITALIC); 37 | 38 | pub(super) const HEADING_STYLE: [Style; 6] = [ 39 | Style::new() 40 | .add_modifier(Modifier::union(HEADER_MODIFIER[0], Modifier::UNDERLINED)) 41 | .fg(HEADER_COLOR[0]), 42 | Style::new() 43 | .add_modifier(Modifier::union(HEADER_MODIFIER[1], Modifier::UNDERLINED)) 44 | .fg(HEADER_COLOR[1]), 45 | Style::new() 46 | .add_modifier(Modifier::union(HEADER_MODIFIER[2], Modifier::UNDERLINED)) 47 | .fg(HEADER_COLOR[2]), 48 | Style::new() 49 | .add_modifier(Modifier::union(HEADER_MODIFIER[3], Modifier::UNDERLINED)) 50 | .fg(HEADER_COLOR[3]), 51 | Style::new() 52 | .add_modifier(Modifier::union(HEADER_MODIFIER[4], Modifier::UNDERLINED)) 53 | .fg(HEADER_COLOR[4]), 54 | Style::new() 55 | .add_modifier(Modifier::union(HEADER_MODIFIER[5], Modifier::UNDERLINED)) 56 | .fg(HEADER_COLOR[5]), 57 | ]; 58 | 59 | pub trait InlineElement: Sized { 60 | fn raw>>(content: T) -> Self; 61 | fn parse_node(node: &mdast::Node) -> Vec; 62 | fn get_inner_span(&self) -> &Span<'static>; 63 | fn get_inner_span_mut(&mut self) -> &mut Span<'static>; 64 | 65 | fn is_empty(&self) -> bool { 66 | self.get_inner_span().content.is_empty() 67 | } 68 | 69 | fn inner_text(&self) -> &str { 70 | self.get_inner_span().content.as_ref() 71 | } 72 | fn into_span(self) -> Span<'static> { 73 | self.get_inner_span().clone() 74 | } 75 | 76 | fn patch_style(&mut self, style: Style) { 77 | *self.get_inner_span_mut() = self.get_inner_span().clone().patch_style(style); 78 | } 79 | } 80 | 81 | pub trait ChainInlineElement: InlineElement + Sized { 82 | fn patch_style(mut self, style: Style) -> Self { 83 | InlineElement::patch_style(&mut self, style); 84 | self 85 | } 86 | } 87 | 88 | impl ChainInlineElement for T where T: InlineElement + Sized {} 89 | 90 | pub trait BlockElement: Sized 91 | where 92 | T: InlineElement, 93 | { 94 | fn parse_node(node: &mdast::Node) -> Vec; 95 | fn content(self) -> Vec; 96 | fn get_content(&self) -> &[T]; 97 | fn get_content_mut(&mut self) -> &mut [T]; 98 | fn render_lines(&self) -> RenderedBlock; 99 | 100 | fn len(&self) -> usize { 101 | self.get_content().len() 102 | } 103 | } 104 | 105 | #[derive(Debug, Clone)] 106 | pub struct RenderedBlock { 107 | content: Vec>, 108 | } 109 | 110 | impl RenderedBlock { 111 | pub fn lines(&self) -> &[Line<'static>] { 112 | &self.content 113 | } 114 | 115 | pub fn build_paragraph(self) -> Paragraph<'static> { 116 | Paragraph::new(self.content) 117 | } 118 | 119 | pub fn wrap_lines(self, max_len: usize) -> Self { 120 | let new_content: Vec> = self 121 | .content 122 | .into_iter() 123 | .flat_map(|line| { 124 | let mut new_lines: Vec> = vec![Line::from(Vec::new())]; 125 | let mut current_size: usize = 0; 126 | 127 | for span in &line.spans { 128 | let mut new_span: String = String::new(); 129 | for grapheme in UnicodeSegmentation::graphemes(span.content.as_ref(), true) { 130 | if current_size == max_len || grapheme == "\n" || grapheme == "\r\n" { 131 | new_lines 132 | .last_mut() 133 | .unwrap() 134 | .spans 135 | .push(Span::raw(new_span.clone()).style(span.style)); 136 | 137 | new_span = String::new(); 138 | new_lines.push(Line::from(Vec::new())); 139 | current_size = 0; 140 | } 141 | 142 | new_span.push_str(grapheme); 143 | current_size += 1; 144 | } 145 | if !new_span.is_empty() { 146 | new_lines 147 | .last_mut() 148 | .unwrap() 149 | .spans 150 | .push(Span::raw(new_span).style(span.style)); 151 | } 152 | } 153 | 154 | new_lines.into_iter().map(move |l| Line { 155 | alignment: line.alignment, 156 | ..l 157 | }) 158 | }) 159 | .collect(); 160 | Self { 161 | content: new_content, 162 | } 163 | } 164 | 165 | pub fn line_count(&self) -> usize { 166 | self.content.len() 167 | } 168 | } 169 | 170 | impl From>> for RenderedBlock { 171 | fn from(content: Vec>) -> Self { 172 | Self { content } 173 | } 174 | } 175 | 176 | #[derive(Debug, Clone)] 177 | pub enum InlineElements { 178 | RawText { span: Span<'static> }, 179 | RichText { span: Span<'static> }, 180 | HyperLink { span: Span<'static>, dest: String }, 181 | CrossRef { span: Span<'static>, dest: String }, 182 | } 183 | 184 | impl InlineElement for InlineElements { 185 | fn raw>>(content: T) -> Self { 186 | Self::RawText { 187 | span: Span::raw(content), 188 | } 189 | } 190 | 191 | fn parse_node(node: &mdast::Node) -> Vec { 192 | match node { 193 | mdast::Node::Emphasis(italic) => italic 194 | .children 195 | .iter() 196 | .flat_map(InlineElements::parse_node) 197 | .filter(|el| !el.is_empty()) 198 | .map(|el| ChainInlineElement::patch_style(el, ITALIC_STYLE)) 199 | .collect(), 200 | mdast::Node::Strong(strong) => strong 201 | .children 202 | .iter() 203 | .flat_map(InlineElements::parse_node) 204 | .filter(|el| !el.is_empty()) 205 | .map(|el| ChainInlineElement::patch_style(el, STRONG_STYLE)) 206 | .collect(), 207 | mdast::Node::Link(link) => vec![InlineElements::HyperLink { 208 | span: Span::raw( 209 | link.children 210 | .iter() 211 | .flat_map(InlineElements::parse_node) 212 | .filter(|el| !el.is_empty()) 213 | .map(|el| el.inner_text().to_string()) 214 | .collect::(), 215 | ) 216 | .style(HYPER_LINK_STYLE), 217 | dest: link.url.to_string(), 218 | }], 219 | mdast::Node::Text(text) => parse_cross_links(text.value.as_str()), 220 | _ => Vec::new(), 221 | } 222 | } 223 | 224 | fn get_inner_span(&self) -> &Span<'static> { 225 | match self { 226 | Self::RawText { span } 227 | | Self::RichText { span } 228 | | Self::HyperLink { span, .. } 229 | | Self::CrossRef { span, .. } => span, 230 | } 231 | } 232 | 233 | fn get_inner_span_mut(&mut self) -> &mut Span<'static> { 234 | match self { 235 | Self::RawText { span } 236 | | Self::RichText { span } 237 | | Self::HyperLink { span, .. } 238 | | Self::CrossRef { span, .. } => span, 239 | } 240 | } 241 | } 242 | 243 | impl InlineElements { 244 | pub fn link_dest(&self) -> Option<&str> { 245 | match self { 246 | Self::CrossRef { dest, .. } => Some(dest), 247 | _ => None, 248 | } 249 | } 250 | } 251 | 252 | #[derive(Debug, Clone)] 253 | pub struct SelectableInlineElements { 254 | pub element: InlineElements, 255 | pub selected: bool, 256 | } 257 | 258 | impl SelectableInlineElements { 259 | pub fn select(&mut self, selected: bool) { 260 | self.selected = selected; 261 | } 262 | } 263 | 264 | impl From for SelectableInlineElements { 265 | fn from(element: InlineElements) -> Self { 266 | Self { 267 | element, 268 | selected: false, 269 | } 270 | } 271 | } 272 | 273 | impl<'a> From<&'a SelectableInlineElements> for &'a InlineElements { 274 | fn from(selectable_element: &'a SelectableInlineElements) -> Self { 275 | &selectable_element.element 276 | } 277 | } 278 | 279 | impl InlineElement for SelectableInlineElements { 280 | fn raw>>(content: T) -> Self { 281 | Self { 282 | element: InlineElements::raw(content), 283 | selected: false, 284 | } 285 | } 286 | 287 | fn parse_node(node: &mdast::Node) -> Vec { 288 | InlineElements::parse_node(node) 289 | .into_iter() 290 | .map(SelectableInlineElements::from) 291 | .collect() 292 | } 293 | 294 | fn get_inner_span(&self) -> &Span<'static> { 295 | self.element.get_inner_span() 296 | } 297 | 298 | fn get_inner_span_mut(&mut self) -> &mut Span<'static> { 299 | self.element.get_inner_span_mut() 300 | } 301 | 302 | fn into_span(self) -> Span<'static> { 303 | let span = self.element.into_span(); 304 | 305 | if self.selected { 306 | span.on_black() 307 | } else { 308 | span 309 | } 310 | } 311 | } 312 | 313 | #[derive(Debug)] 314 | pub enum BlockElements 315 | where 316 | T: InlineElement, 317 | { 318 | Paragraph { content: Vec }, 319 | Heading { content: Vec, level: u8 }, 320 | BlockQuote { content: Vec }, 321 | ListItem { content: Vec }, 322 | UnformatedText { content: Vec }, 323 | } 324 | 325 | impl BlockElement for BlockElements 326 | where 327 | T: InlineElement + Clone, 328 | { 329 | fn parse_node(node: &mdast::Node) -> Vec> { 330 | match node { 331 | mdast::Node::Root(root) => root 332 | .children 333 | .iter() 334 | .flat_map(BlockElements::parse_node) 335 | .collect(), 336 | mdast::Node::Blockquote(blockquote) => vec![Self::BlockQuote { 337 | content: blockquote 338 | .children 339 | .iter() 340 | .flat_map(BlockElements::parse_node) 341 | .flat_map(BlockElements::content) 342 | .collect(), 343 | }], 344 | mdast::Node::Heading(heading) => vec![Self::Heading { 345 | level: heading.depth - 1, 346 | content: heading 347 | .children 348 | .iter() 349 | .flat_map(InlineElement::parse_node) 350 | .collect(), 351 | }], 352 | mdast::Node::Paragraph(paragraph) => vec![Self::Paragraph { 353 | content: paragraph 354 | .children 355 | .iter() 356 | .flat_map(InlineElement::parse_node) 357 | .collect(), 358 | }], 359 | mdast::Node::List(list) => list 360 | .children 361 | .iter() 362 | .filter_map(|el| { 363 | if let mdast::Node::ListItem(item) = el { 364 | Some(item) 365 | } else { 366 | None 367 | } 368 | }) 369 | .map(|item| Self::ListItem { 370 | content: item 371 | .children 372 | .iter() 373 | .flat_map(BlockElements::parse_node) 374 | .flat_map(BlockElements::content) 375 | .collect(), 376 | }) 377 | .collect(), 378 | mdast::Node::Code(code) if code.lang.is_none() => vec![Self::UnformatedText { 379 | content: code 380 | .value 381 | .lines() 382 | .map(String::from) 383 | .map(InlineElement::raw) 384 | .collect(), 385 | }], 386 | _ => Vec::new(), 387 | } 388 | } 389 | 390 | fn content(self) -> Vec { 391 | match self { 392 | Self::Paragraph { content } 393 | | Self::Heading { content, .. } 394 | | Self::BlockQuote { content } 395 | | Self::ListItem { content } 396 | | Self::UnformatedText { content } => content, 397 | } 398 | } 399 | 400 | fn get_content(&self) -> &[T] { 401 | match self { 402 | Self::Paragraph { content } 403 | | Self::Heading { content, .. } 404 | | Self::BlockQuote { content } 405 | | Self::ListItem { content } 406 | | Self::UnformatedText { content } => content, 407 | } 408 | } 409 | 410 | fn get_content_mut(&mut self) -> &mut [T] { 411 | match self { 412 | Self::Paragraph { content } 413 | | Self::Heading { content, .. } 414 | | Self::BlockQuote { content } 415 | | Self::ListItem { content } 416 | | Self::UnformatedText { content } => content, 417 | } 418 | } 419 | 420 | fn render_lines(&self) -> RenderedBlock { 421 | match self { 422 | Self::Paragraph { content } => { 423 | vec![ 424 | Line::from( 425 | content 426 | .iter() 427 | .cloned() 428 | .map(InlineElement::into_span) 429 | .collect::>>(), 430 | ), 431 | Line::default(), 432 | ] 433 | } 434 | BlockElements::Heading { content, level } => vec![Line::from( 435 | content 436 | .iter() 437 | .cloned() 438 | .map(|el| ChainInlineElement::patch_style(el, HEADING_STYLE[*level as usize])) 439 | .map(InlineElement::into_span) 440 | .collect::>(), 441 | ) 442 | .alignment(HEADER_ALIGNEMENT[*level as usize])], 443 | BlockElements::BlockQuote { content } => vec![ 444 | Line::from( 445 | content 446 | .iter() 447 | .cloned() 448 | .map(|el| ChainInlineElement::patch_style(el, BLOCKQUOTE_STYLE)) 449 | .map(InlineElement::into_span) 450 | .collect::>(), 451 | ) 452 | .alignment(BLOCKQUOTE_ALIGNEMENT), 453 | Line::default(), 454 | ], 455 | BlockElements::ListItem { content } => vec![Line::from( 456 | [Span::raw(" - ").style(Style::new().fg(Color::Blue))] 457 | .into_iter() 458 | .chain(content.iter().cloned().map(InlineElement::into_span)) 459 | .collect::>(), 460 | )], 461 | BlockElements::UnformatedText { content } => content 462 | .iter() 463 | .cloned() 464 | .map(InlineElement::into_span) 465 | .map(|span| Line::from(vec![span])) 466 | .collect(), 467 | } 468 | .into() 469 | } 470 | } 471 | 472 | fn parse_cross_links(text: &str) -> Vec { 473 | let mut content_iter = text.chars().peekable(); 474 | let mut escape = false; 475 | let mut cross_ref = false; 476 | let mut current_span = String::new(); 477 | let mut spans = Vec::new(); 478 | 479 | while let Some(c) = content_iter.next() { 480 | if cross_ref { 481 | if c == ']' && matches!(content_iter.peek(), Some(']')) { 482 | spans.push(InlineElements::CrossRef { 483 | span: Span::raw(format!("[{current_span}]")).style(CROSS_REF_STYLE), 484 | dest: current_span, 485 | }); 486 | current_span = String::new(); 487 | cross_ref = false; 488 | content_iter.next(); 489 | } else { 490 | current_span.push(c); 491 | } 492 | } else { 493 | if escape { 494 | current_span.push(c); 495 | escape = false; 496 | continue; 497 | } 498 | 499 | if c == '[' && matches!(content_iter.peek(), Some('[')) { 500 | spans.push(InlineElements::RichText { 501 | span: Span::raw(current_span).style(TEXT_STYLE), 502 | }); 503 | current_span = String::new(); 504 | cross_ref = true; 505 | 506 | content_iter.next(); 507 | } else { 508 | current_span.push(c); 509 | } 510 | } 511 | } 512 | 513 | if !current_span.is_empty() { 514 | spans.push(InlineElements::RichText { 515 | span: Span::raw(current_span), 516 | }); 517 | } 518 | 519 | spans.retain(|el| !el.is_empty()); 520 | 521 | spans 522 | } 523 | --------------------------------------------------------------------------------