Compare commits

...

22 Commits
v0.2.0 ... main

Author SHA1 Message Date
c96eebf6f9 chore(deps): update rust crate anyhow to v1.0.89
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-15 04:31:24 +00:00
9d9d6be3b7 chore(deps): update rust crate anyhow to v1.0.88
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-12 00:33:33 +00:00
f106929cca chore(deps): update rust crate anyhow to v1.0.87
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-07 04:29:21 +00:00
d876891242 fix(deps): update rust crate serde to v1.0.210
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-07 00:31:58 +00:00
6961987a77 chore(deps): update rust crate similar-asserts to v1.6.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-06 00:30:16 +00:00
d3695eba50 chore(deps): update all dependencies
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-05 00:32:33 +00:00
f175c4ebcf fix(deps): update rust crate sqlx to v0.8.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-04 00:27:32 +00:00
58df153c6e fix(deps): update rust crate prost to v0.13.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-31 00:28:05 +00:00
2b7a05bc4e chore(deps): update rust crate tokio to v1.40.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-30 08:27:22 +00:00
fcb0ea7393 chore(deps): update tonic monorepo to v0.12.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-26 20:42:40 +00:00
3a1741d7dc fix(deps): update rust crate ratatui to v0.28.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-25 09:51:50 +00:00
8886af4a8f fix(deps): update rust crate sqlx to v0.8.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-24 08:07:53 +00:00
08a6f77146 fix(deps): update rust crate serde to v1.0.209
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-24 03:54:43 +00:00
c15c7f0ae2 fix(deps): update rust crate serde_json to v1.0.127
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-23 21:19:27 +00:00
04c6e97f30 fix(deps): update rust crate serde_json to v1.0.126
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-23 20:40:42 +00:00
29e0c37599 fix(deps): update rust crate prost to 0.13.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-21 23:30:14 +00:00
e655c57f21 chore(deps): update tonic monorepo to 0.12.0
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2024-08-21 22:31:50 +00:00
28fb99e6f9 fix(deps): update rust crate serde to v1.0.208
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-21 23:22:49 +02:00
3a231cea96 chore(deps): update all dependencies
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-21 21:02:46 +00:00
b3c1784cae Merge pull request 'chore(release): v0.3.0' (#12) from cuddle-please/release into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #12
2024-07-05 23:10:26 +02:00
cuddle-please
a790e7f039 chore(release): 0.3.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-06-30 15:34:55 +00:00
20190ac784
feat: add markdown editing mode
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-06-30 17:31:25 +02:00
16 changed files with 754 additions and 461 deletions

View File

@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.3.0] - 2024-06-30
### Added
- add markdown editing mode
## [0.2.0] - 2024-05-25 ## [0.2.0] - 2024-05-25
### Added ### Added

798
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -19,8 +19,11 @@ serde = { version = "1.0.202", features = ["derive"] }
serde_json = "1.0.117" serde_json = "1.0.117"
itertools = "0.13.0" itertools = "0.13.0"
uuid = { version = "1.8.0", features = ["v4"] } uuid = { version = "1.8.0", features = ["v4"] }
tonic = { version = "0.11.0", features = ["tls", "tls-roots"] } tonic = { version = "0.12.0", features = ["tls", "tls-roots"] }
futures = { version = "0.3.30" } futures = { version = "0.3.30" }
sha2 = { version = "0.10.8" }
hex = { version = "0.4.3" }
toml = { version = "0.8.14" }
[workspace.package] [workspace.package]
version = "0.2.0" version = "0.3.0"

View File

@ -6,7 +6,7 @@ version.workspace = true
[dependencies] [dependencies]
tonic.workspace = true tonic.workspace = true
prost = "0.12.6" prost = "0.13.0"
[build-dependencies] [build-dependencies]
tonic-build = "0.11.0" tonic-build = "0.12.0"

View File

@ -17,7 +17,7 @@ uuid.workspace = true
tonic.workspace = true tonic.workspace = true
tower-http = { version = "0.5.2", features = ["cors", "trace"] } tower-http = { version = "0.5.2", features = ["cors", "trace"] }
sqlx = { version = "0.7.4", features = [ sqlx = { version = "0.8.0", features = [
"runtime-tokio", "runtime-tokio",
"tls-rustls", "tls-rustls",
"postgres", "postgres",

View File

@ -17,9 +17,13 @@ serde_json.workspace = true
itertools.workspace = true itertools.workspace = true
tonic.workspace = true tonic.workspace = true
futures.workspace = true futures.workspace = true
sha2.workspace = true
uuid.workspace = true
hex.workspace = true
toml.workspace = true
ratatui = "0.26.2" ratatui = "0.28.0"
crossterm = { version = "0.27.0", features = ["event-stream"] } crossterm = { version = "0.28.0", features = ["event-stream"] }
directories = "5.0.1" directories = "5.0.1"
human-panic = "2.0.0" human-panic = "2.0.0"
ropey = "1.6.1" ropey = "1.6.1"

View File

@ -1,4 +1,5 @@
use hyperlog_core::log::GraphItem; use hyperlog_core::log::GraphItem;
use itertools::Itertools;
use ratatui::{ use ratatui::{
prelude::*, prelude::*,
widgets::{Block, Borders, Padding, Paragraph}, widgets::{Block, Borders, Padding, Paragraph},
@ -6,8 +7,9 @@ use ratatui::{
use crate::{ use crate::{
command_parser::CommandParser, command_parser::CommandParser,
commands::{batch::BatchCommand, IntoCommand}, commands::{batch::BatchCommand, update_item::UpdateItemCommandExt, Command, IntoCommand},
components::graph_explorer::GraphExplorer, components::graph_explorer::GraphExplorer,
editor,
models::IOEvent, models::IOEvent,
state::SharedState, state::SharedState,
Msg, Msg,
@ -102,6 +104,11 @@ impl<'a> App<'a> {
Msg::MoveUp => self.graph_explorer.move_up()?, Msg::MoveUp => self.graph_explorer.move_up()?,
Msg::OpenCreateItemDialog => self.open_dialog(), Msg::OpenCreateItemDialog => self.open_dialog(),
Msg::OpenCreateItemDialogBelow => self.open_dialog_below(), Msg::OpenCreateItemDialogBelow => self.open_dialog_below(),
Msg::OpenEditor { item } => {
if let Some(cmd) = self.open_editor(item) {
batch.with(cmd);
}
}
Msg::OpenEditItemDialog { item } => self.open_edit_item_dialog(item), Msg::OpenEditItemDialog { item } => self.open_edit_item_dialog(item),
Msg::EnterInsertMode => self.mode = Mode::Insert, Msg::EnterInsertMode => self.mode = Mode::Insert,
Msg::EnterViewMode => self.mode = Mode::View, Msg::EnterViewMode => self.mode = Mode::View,
@ -212,6 +219,42 @@ impl<'a> App<'a> {
self.mode = Mode::Insert; self.mode = Mode::Insert;
} }
} }
fn open_editor(&self, item: &GraphItem) -> Option<Command> {
match editor::EditorSession::new(item).execute() {
Ok(None) => {
tracing::info!("editor returned without changes, skipping");
}
Ok(Some(item)) => {
if let GraphItem::Item {
title,
description,
state,
} = item
{
return Some(
self.state.update_item_command().command(
&self.root,
&self
.graph_explorer
.get_current_path()
.iter()
.map(|s| s.as_str())
.collect_vec(),
&title,
&description,
state,
),
);
}
}
Err(e) => {
tracing::error!("failed to run editor with: {}", e);
}
}
None
}
} }
impl<'a> Widget for &mut App<'a> { impl<'a> Widget for &mut App<'a> {

View File

@ -9,6 +9,7 @@ pub enum Commands {
CreateItem { name: String }, CreateItem { name: String },
CreateBelow { name: String }, CreateBelow { name: String },
Edit, Edit,
Open,
ShowAll, ShowAll,
HideDone, HideDone,
@ -52,6 +53,7 @@ impl CommandParser {
"show-all" => Some(Commands::ShowAll), "show-all" => Some(Commands::ShowAll),
"hide-done" => Some(Commands::HideDone), "hide-done" => Some(Commands::HideDone),
"test" => Some(Commands::Test), "test" => Some(Commands::Test),
"o" | "open" => Some(Commands::Open),
_ => None, _ => None,
}, },
None => None, None => None,

View File

@ -5,6 +5,7 @@ pub mod batch;
pub mod archive; pub mod archive;
pub mod create_item; pub mod create_item;
pub mod create_section; pub mod create_section;
pub mod open_item;
pub mod open_update_item_dialog; pub mod open_update_item_dialog;
pub mod toggle_item; pub mod toggle_item;
pub mod update_graph; pub mod update_graph;

View File

@ -1,4 +1,3 @@
use hyperlog_core::log::ItemState;
use itertools::Itertools; use itertools::Itertools;
use crate::{ use crate::{

View File

@ -0,0 +1,59 @@
use crate::{
models::{IOEvent, Msg},
querier::Querier,
state::SharedState,
};
pub struct OpenItemCommand {
querier: Querier,
}
impl OpenItemCommand {
pub fn new(querier: Querier) -> Self {
Self { querier }
}
pub fn command(self, root: &str, path: Vec<String>) -> super::Command {
let root = root.to_string();
super::Command::new(|dispatch| {
tokio::spawn(async move {
dispatch.send(Msg::OpenItem(IOEvent::Initialized));
let item = match self.querier.get_async(&root, path).await {
Ok(item) => match item {
Some(item) => {
dispatch.send(Msg::OpenItem(IOEvent::Success(())));
item
}
None => {
dispatch.send(Msg::OpenItem(IOEvent::Failure(
"failed to find a valid item for path".into(),
)));
return;
}
},
Err(e) => {
dispatch.send(Msg::OpenItem(IOEvent::Failure(e.to_string())));
return;
}
};
dispatch.send(Msg::OpenEditor { item });
});
None
})
}
}
pub trait OpenItemCommandExt {
fn open_item_command(&self) -> OpenItemCommand;
}
impl OpenItemCommandExt for SharedState {
fn open_item_command(&self) -> OpenItemCommand {
OpenItemCommand::new(self.querier.clone())
}
}

View File

@ -7,7 +7,7 @@ use crate::{
command_parser::Commands, command_parser::Commands,
commands::{ commands::{
archive::ArchiveCommandExt, batch::BatchCommand, create_item::CreateItemCommandExt, archive::ArchiveCommandExt, batch::BatchCommand, create_item::CreateItemCommandExt,
create_section::CreateSectionCommandExt, create_section::CreateSectionCommandExt, open_item::OpenItemCommandExt,
open_update_item_dialog::OpenUpdateItemDialogCommandExt, toggle_item::ToggleItemCommandExt, open_update_item_dialog::OpenUpdateItemDialogCommandExt, toggle_item::ToggleItemCommandExt,
update_graph::UpdateGraphCommandExt, Command, IntoCommand, update_graph::UpdateGraphCommandExt, Command, IntoCommand,
}, },
@ -144,7 +144,7 @@ impl<'a> GraphExplorer<'a> {
/// Choses: 0.1.0.0 else nothing /// Choses: 0.1.0.0 else nothing
pub(crate) fn move_right(&mut self) -> Result<()> { pub(crate) fn move_right(&mut self) -> Result<()> {
if let Some(graph) = self.linearize_graph() { if let Some(graph) = self.linearize_graph() {
tracing::debug!("graph: {:?}", graph); tracing::trace!("graph: {:?}", graph);
let position_items = &self.inner.current_position; let position_items = &self.inner.current_position;
if let Some(next_item) = graph.next_right(position_items) { if let Some(next_item) = graph.next_right(position_items) {
@ -338,6 +338,15 @@ impl<'a> GraphExplorer<'a> {
None None
}))); })));
} }
Commands::Open => {
if self.get_current_item().is_some() {
batch.with(
self.state
.open_item_command()
.command(&self.inner.root, self.get_current_path()),
);
}
}
_ => (), _ => (),
} }

View File

@ -0,0 +1,260 @@
use std::{
io::{Read, Write},
path::{Path, PathBuf},
time::SystemTime,
};
use anyhow::{anyhow, Context};
use hyperlog_core::log::{GraphItem, ItemState};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use sha2::Digest;
use crate::project_dirs::get_project_dir;
pub struct EditorSession<'a> {
item: &'a GraphItem,
}
struct EditorFile {
title: String,
metadata: Metadata,
body: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct Metadata {
state: ItemState,
}
impl EditorFile {
pub fn serialize(&self) -> anyhow::Result<String> {
let metadata =
toml::to_string_pretty(&self.metadata).context("failed to serialize metadata")?;
let frontmatter = format!("+++\n{}+++\n", metadata);
Ok(format!(
"{}\n# {}\n\n{}",
frontmatter, self.title, self.body
))
}
}
impl TryFrom<&GraphItem> for EditorFile {
type Error = anyhow::Error;
fn try_from(value: &GraphItem) -> Result<Self, Self::Error> {
if let GraphItem::Item {
title,
description,
state,
} = value.clone()
{
Ok(Self {
title,
metadata: Metadata { state },
body: description,
})
} else {
anyhow::bail!("can only generate a file based on items")
}
}
}
impl TryFrom<&str> for EditorFile {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let value = value.to_string();
let frontmatter_parts = value.split("+++").filter(|p| !p.is_empty()).collect_vec();
let frontmatter_content = frontmatter_parts
.first()
.ok_or(anyhow::anyhow!("no front matter parts were found"))?;
tracing::trace!("parsing frontmatter content: {}", frontmatter_content);
let metadata: Metadata = toml::from_str(frontmatter_content)?;
let line_parts = value.split("\n");
let title = line_parts
.clone()
.find(|p| p.starts_with("# "))
.map(|t| t.trim_start_matches("# "))
.ok_or(anyhow!("an editor file requires a title with heading 1"))?;
let body = line_parts
.skip_while(|p| !p.starts_with("# "))
.skip(1)
.skip_while(|p| p.is_empty())
.collect_vec()
.join("\n");
Ok(Self {
title: title.to_string(),
metadata,
body,
})
}
}
impl From<EditorFile> for GraphItem {
fn from(value: EditorFile) -> Self {
Self::Item {
title: value.title,
description: value.body,
state: value.metadata.state,
}
}
}
struct SessionFile {
path: PathBuf,
loaded: SystemTime,
}
impl SessionFile {
pub fn get_path(&self) -> &Path {
self.path.as_path()
}
pub fn is_changed(&self) -> anyhow::Result<bool> {
let modified = self.path.metadata()?.modified()?;
Ok(self.loaded < modified)
}
}
impl Drop for SessionFile {
fn drop(&mut self) {
if self.path.exists() {
tracing::debug!("cleaning up file: {}", self.path.display());
if let Err(e) = std::fs::remove_file(&self.path) {
tracing::error!(
"failed to cleanup file: {}, error: {}",
self.path.display(),
e
);
}
}
}
}
impl<'a> EditorSession<'a> {
pub fn new(item: &'a GraphItem) -> Self {
Self { item }
}
fn get_file_path(&mut self) -> anyhow::Result<PathBuf> {
let name = self
.item
.get_digest()
.ok_or(anyhow::anyhow!("item doesn't have a title"))?;
let file_path = get_project_dir()
.data_dir()
.join("edit")
.join(format!("{name}.md"));
Ok(file_path)
}
fn prepare_file(&mut self) -> anyhow::Result<SessionFile> {
let file_path = self.get_file_path()?;
if let Some(parent) = file_path.parent() {
tracing::debug!("creating parent dir: {}", parent.display());
std::fs::create_dir_all(parent).context("failed to create dir for edit file")?;
}
let mut file =
std::fs::File::create(&file_path).context("failed to create file for edit file")?;
tracing::debug!("writing contents to file: {}", file_path.display());
let editor_file = EditorFile::try_from(self.item)?;
file.write_all(
editor_file
.serialize()
.context("failed to serialize item to file")?
.as_bytes(),
)
.context("failed to write to file")?;
let modified_time = file.metadata()?.modified()?;
Ok(SessionFile {
path: file_path,
loaded: modified_time,
})
}
fn get_item_from_file(&self, session_file: SessionFile) -> anyhow::Result<GraphItem> {
let mut file = std::fs::File::open(&session_file.path)?;
let mut content = String::new();
file.read_to_string(&mut content)?;
let editor_file = EditorFile::try_from(content.as_str())?;
Ok(editor_file.into())
}
pub fn execute(&mut self) -> anyhow::Result<Option<GraphItem>> {
let editor = std::env::var("EDITOR").context("no editor was found for EDITOR env var")?;
let session_file = self.prepare_file()?;
tracing::debug!(
"opening editor: {} at path: {}",
editor,
session_file.get_path().display()
);
if let Err(e) = std::process::Command::new(editor)
.arg(session_file.get_path())
.status()
{
tracing::error!("failed command with: {}", e);
return Ok(None);
}
tracing::debug!(
"returning from editor, checking file: {}",
session_file.get_path().display()
);
if session_file.is_changed()? {
tracing::debug!(
"file: {} changed, updating item",
session_file.get_path().display()
);
Ok(Some(self.get_item_from_file(session_file)?))
} else {
Ok(None)
}
}
}
trait ItemExt {
fn get_digest(&self) -> Option<String>;
}
impl<'a> ItemExt for &'a GraphItem {
fn get_digest(&self) -> Option<String> {
if let GraphItem::Item { title, .. } = self {
let digest = sha2::Sha256::digest(title.as_bytes());
let digest_hex = hex::encode(digest);
Some(format!(
"{}_{}",
title
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(10)
.collect::<String>(),
digest_hex.chars().take(10).collect::<String>()
))
} else {
None
}
}
}

View File

@ -33,7 +33,9 @@ mod events;
mod querier; mod querier;
mod storage; mod storage;
mod editor;
mod logging; mod logging;
mod project_dirs;
mod terminal; mod terminal;
pub async fn execute(state: State) -> Result<()> { pub async fn execute(state: State) -> Result<()> {

View File

@ -12,6 +12,7 @@ pub enum Msg {
OpenCreateItemDialog, OpenCreateItemDialog,
OpenCreateItemDialogBelow, OpenCreateItemDialogBelow,
OpenEditItemDialog { item: GraphItem }, OpenEditItemDialog { item: GraphItem },
OpenEditor { item: GraphItem },
Interact, Interact,
EnterInsertMode, EnterInsertMode,
@ -30,6 +31,8 @@ pub enum Msg {
Archive(IOEvent<()>), Archive(IOEvent<()>),
OpenUpdateItemDialog(IOEvent<()>), OpenUpdateItemDialog(IOEvent<()>),
OpenItem(IOEvent<()>),
} }
#[derive(Debug)] #[derive(Debug)]

View File

@ -0,0 +1,5 @@
use directories::ProjectDirs;
pub fn get_project_dir() -> ProjectDirs {
ProjectDirs::from("io", "kjuulh", "hyperlog").expect("to be able to get project dirs")
}