Firts Commit: new app
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
2545
Cargo.lock
generated
Normal file
2545
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
Cargo.toml
Normal file
26
Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "memoscli"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["SidneyZhang<zly@lyzhang.me>"]
|
||||
description = "A command-line tool to manage memos"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.28"
|
||||
crossterm = "0.28"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
directories = "5.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
anyhow = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
tracing-appender = "0.2"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
17
LICENSE
Normal file
17
LICENSE
Normal file
@@ -0,0 +1,17 @@
|
||||
Copyright 2026 SidneyZhang
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the “Software”), to deal in the
|
||||
Software without restriction, including without limitation the rights to use, copy,
|
||||
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
|
||||
and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies
|
||||
or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
|
||||
AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
5
README.md
Normal file
5
README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# MemosCLI
|
||||
|
||||
使用命令行管理自己部署的Memos。
|
||||
|
||||
[Memos](https://usememos.com/)
|
||||
55
src/cli.rs
Normal file
55
src/cli.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "memoscli")]
|
||||
#[command(about = "A CLI tool to manage memos", long_about = None)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub enum Command {
|
||||
Create(CreateCommand),
|
||||
List(ListCommand),
|
||||
Config(ConfigCommand),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct CreateCommand {
|
||||
#[arg(short, long, help = "Content of the memo")]
|
||||
pub content: Option<String>,
|
||||
|
||||
#[arg(short, long, help = "Markdown file to use as content")]
|
||||
pub file: Option<PathBuf>,
|
||||
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
default_value = "PRIVATE",
|
||||
help = "Visibility: PUBLIC or PRIVATE"
|
||||
)]
|
||||
pub visibility: String,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ListCommand {
|
||||
#[arg(short, long, default_value = "20", help = "Number of memos to list")]
|
||||
pub limit: i32,
|
||||
|
||||
#[arg(short, long, default_value = "0", help = "Offset for pagination")]
|
||||
pub offset: i32,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ConfigCommand {
|
||||
#[arg(short, long, help = "Set the base URL")]
|
||||
pub base_url: Option<String>,
|
||||
|
||||
#[arg(short, long, help = "Set the user token")]
|
||||
pub token: Option<String>,
|
||||
|
||||
#[arg(short, long, action = clap::ArgAction::SetTrue, help = "Show current configuration")]
|
||||
pub show: bool,
|
||||
}
|
||||
93
src/client.rs
Normal file
93
src/client.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use crate::models::{CreateMemoRequest, CreateMemoResponse, ListMemosResponse, Memo};
|
||||
use anyhow::Result;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MemosClient {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
user_token: String,
|
||||
}
|
||||
|
||||
impl MemosClient {
|
||||
pub fn new(base_url: &str, user_token: &str) -> Result<Self> {
|
||||
let base_url = base_url.trim_end_matches('/');
|
||||
Ok(Self {
|
||||
client: Client::new(),
|
||||
base_url: base_url.to_string(),
|
||||
user_token: user_token.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_memo(&self, content: &str, visibility: Option<&str>) -> Result<Memo> {
|
||||
let url = format!("{}/api/v1/memos", self.base_url);
|
||||
|
||||
let request = CreateMemoRequest {
|
||||
content: content.to_string(),
|
||||
visibility: visibility.map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
let response = self.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.user_token))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to create memo: {} - {}", status, error_text);
|
||||
}
|
||||
|
||||
let result: CreateMemoResponse = response.json().await?;
|
||||
|
||||
self.get_memo(result.id).await
|
||||
}
|
||||
|
||||
pub async fn get_memo(&self, id: i64) -> Result<Memo> {
|
||||
let url = format!("{}/api/v1/memos/{}", self.base_url, id);
|
||||
|
||||
let response = self.client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.user_token))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to get memo: {} - {}", status, error_text);
|
||||
}
|
||||
|
||||
Ok(response.json().await?)
|
||||
}
|
||||
|
||||
pub async fn list_memos(&self, limit: Option<i32>, offset: Option<i32>) -> Result<Vec<Memo>> {
|
||||
let url = format!("{}/api/v1/memos", self.base_url);
|
||||
|
||||
let mut request = self.client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.user_token));
|
||||
|
||||
if let Some(l) = limit {
|
||||
request = request.query(&[("limit", l.to_string())]);
|
||||
}
|
||||
if let Some(o) = offset {
|
||||
request = request.query(&[("offset", o.to_string())]);
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to list memos: {} - {}", status, error_text);
|
||||
}
|
||||
|
||||
let result: ListMemosResponse = response.json().await?;
|
||||
Ok(result.memos)
|
||||
}
|
||||
}
|
||||
51
src/config.rs
Normal file
51
src/config.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use anyhow::Result;
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub base_url: String,
|
||||
pub user_token: String,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: String::new(),
|
||||
user_token: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn config_path() -> Result<PathBuf> {
|
||||
let proj_dirs = ProjectDirs::from("com", "memoscli", "memoscli")
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get project directories"))?;
|
||||
let config_dir = proj_dirs.config_dir();
|
||||
fs::create_dir_all(config_dir)?;
|
||||
Ok(config_dir.join("config.json"))
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = Self::config_path()?;
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(&path)?;
|
||||
Ok(serde_json::from_str(&content)?)
|
||||
} else {
|
||||
Ok(Config::default())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = Self::config_path()?;
|
||||
let content = serde_json::to_string_pretty(self)?;
|
||||
fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_configured(&self) -> bool {
|
||||
!self.base_url.is_empty() && !self.user_token.is_empty()
|
||||
}
|
||||
}
|
||||
359
src/main.rs
Normal file
359
src/main.rs
Normal file
@@ -0,0 +1,359 @@
|
||||
mod cli;
|
||||
mod client;
|
||||
mod config;
|
||||
mod models;
|
||||
mod tui;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Command};
|
||||
use client::MemosClient;
|
||||
use config::Config;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen};
|
||||
use models::Memo;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
use std::io;
|
||||
use tui::{App, AppMode};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
if let Some(command) = cli.command {
|
||||
return run_cli_command(command).await;
|
||||
}
|
||||
|
||||
run_tui_mode().await
|
||||
}
|
||||
|
||||
async fn run_cli_command(command: Command) -> Result<()> {
|
||||
let config = Config::load().unwrap_or_default();
|
||||
|
||||
if !config.is_configured() {
|
||||
println!("Error: Memos is not configured. Please run 'memoscli config' first.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let client = MemosClient::new(&config.base_url, &config.user_token)?;
|
||||
|
||||
match command {
|
||||
Command::Create(cmd) => {
|
||||
let content = if let Some(file_path) = cmd.file {
|
||||
if cmd.content.is_some() {
|
||||
println!("Error: Cannot use both --content and --file options.");
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::read_to_string(&file_path)?
|
||||
} else if let Some(c) = cmd.content {
|
||||
c
|
||||
} else {
|
||||
println!("Error: Please provide either --content or --file option.");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let visibility = if cmd.visibility.to_uppercase() == "PUBLIC" {
|
||||
Some("PUBLIC")
|
||||
} else {
|
||||
Some("PRIVATE")
|
||||
};
|
||||
|
||||
match client.create_memo(&content, visibility).await {
|
||||
Ok(memo) => {
|
||||
println!("Memo created successfully with ID: {}", memo.id);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error creating memo: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::List(cmd) => {
|
||||
match client.list_memos(Some(cmd.limit), Some(cmd.offset)).await {
|
||||
Ok(memos) => {
|
||||
if memos.is_empty() {
|
||||
println!("No memos found.");
|
||||
} else {
|
||||
for memo in &memos {
|
||||
let created = chrono::DateTime::from_timestamp_millis(memo.created_ts)
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
println!("[{}] {} - {}", memo.id, created, &memo.content[..memo.content.len().min(60)]);
|
||||
}
|
||||
println!("\nTotal: {} memos", memos.len());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error listing memos: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Config(cmd) => {
|
||||
if cmd.show {
|
||||
if config.is_configured() {
|
||||
println!("Base URL: {}", config.base_url);
|
||||
println!("User Token: ***");
|
||||
} else {
|
||||
println!("Not configured. Use --base-url and --token to configure.");
|
||||
}
|
||||
} else {
|
||||
let mut new_config = config.clone();
|
||||
if let Some(url) = cmd.base_url {
|
||||
new_config.base_url = url;
|
||||
}
|
||||
if let Some(token) = cmd.token {
|
||||
new_config.user_token = token;
|
||||
}
|
||||
new_config.save()?;
|
||||
println!("Configuration saved.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_tui_mode() -> Result<()> {
|
||||
let config = Config::load().unwrap_or_default();
|
||||
let mut app = App::new(config);
|
||||
app.input_base_url = app.config.base_url.clone();
|
||||
app.input_token = app.config.user_token.clone();
|
||||
app.init_client().await?;
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| tui::render_app(f, &mut app))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match key.kind {
|
||||
KeyEventKind::Press => {
|
||||
if key.code == KeyCode::Char('q') && !app.loading {
|
||||
if app.mode == AppMode::MainMenu {
|
||||
break;
|
||||
}
|
||||
app.mode = AppMode::MainMenu;
|
||||
app.message = None;
|
||||
}
|
||||
|
||||
if key.code == KeyCode::Esc && !app.loading {
|
||||
match app.mode {
|
||||
AppMode::MainMenu => continue,
|
||||
AppMode::Config => {
|
||||
app.input_base_url = app.config.base_url.clone();
|
||||
app.input_token = app.config.user_token.clone();
|
||||
app.mode = AppMode::MainMenu;
|
||||
}
|
||||
AppMode::CreateMemo => {
|
||||
app.input_content.clear();
|
||||
app.input_visibility = "PRIVATE".to_string();
|
||||
app.mode = AppMode::MainMenu;
|
||||
}
|
||||
AppMode::ListMemos => {
|
||||
app.mode = AppMode::MainMenu;
|
||||
}
|
||||
AppMode::ViewMemo => {
|
||||
app.mode = AppMode::ListMemos;
|
||||
}
|
||||
}
|
||||
app.message = None;
|
||||
}
|
||||
|
||||
match app.mode {
|
||||
AppMode::MainMenu => {
|
||||
match key.code {
|
||||
KeyCode::Char('1') => {
|
||||
if app.config.is_configured() {
|
||||
app.loading = true;
|
||||
terminal.draw(|f| tui::render_app(f, &mut app))?;
|
||||
match load_memos(&app).await {
|
||||
Ok(memos) => {
|
||||
app.memos = memos;
|
||||
app.mode = AppMode::ListMemos;
|
||||
app.message = Some("Memos loaded successfully".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
app.message = Some(format!("Error: {}", e));
|
||||
}
|
||||
}
|
||||
app.loading = false;
|
||||
} else {
|
||||
app.message = Some("Please configure first".to_string());
|
||||
}
|
||||
}
|
||||
KeyCode::Char('2') => {
|
||||
if app.config.is_configured() {
|
||||
app.input_content.clear();
|
||||
app.input_visibility = "PRIVATE".to_string();
|
||||
app.mode = AppMode::CreateMemo;
|
||||
} else {
|
||||
app.message = Some("Please configure first".to_string());
|
||||
}
|
||||
}
|
||||
KeyCode::Char('3') => {
|
||||
app.input_base_url = app.config.base_url.clone();
|
||||
app.input_token = app.config.user_token.clone();
|
||||
app.mode = AppMode::Config;
|
||||
}
|
||||
KeyCode::Char('Q') => {
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
AppMode::Config => {
|
||||
handle_config_input(&mut app, &key).await?;
|
||||
}
|
||||
AppMode::CreateMemo => {
|
||||
handle_create_memo_input(&mut app, &key).await?;
|
||||
}
|
||||
AppMode::ListMemos => {
|
||||
handle_list_memos_input(&mut app, &key).await?;
|
||||
}
|
||||
AppMode::ViewMemo => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disable_raw_mode()?;
|
||||
terminal.show_cursor()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_memos(app: &App) -> Result<Vec<Memo>> {
|
||||
let client = app.get_client().await?;
|
||||
let memos = client.list_memos(None, None).await?;
|
||||
Ok(memos)
|
||||
}
|
||||
|
||||
async fn handle_config_input(app: &mut App, key: &KeyEvent) -> Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Char(c) => {
|
||||
if app.current_field == 0 {
|
||||
app.input_base_url.push(c);
|
||||
} else {
|
||||
app.input_token.push(c);
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if app.current_field == 0 {
|
||||
if !app.input_base_url.is_empty() {
|
||||
app.input_base_url.pop();
|
||||
}
|
||||
} else {
|
||||
if !app.input_token.is_empty() {
|
||||
app.input_token.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.config.base_url = app.input_base_url.clone();
|
||||
app.config.user_token = app.input_token.clone();
|
||||
app.config.save()?;
|
||||
app.refresh_client().await?;
|
||||
app.mode = AppMode::MainMenu;
|
||||
app.message = Some("Configuration saved".to_string());
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
app.current_field = if app.current_field == 0 { 1 } else { 0 };
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_create_memo_input(app: &mut App, key: &KeyEvent) -> Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Char(c) => {
|
||||
app.input_content.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.input_content.pop();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if key.modifiers.contains(event::KeyModifiers::CONTROL) {
|
||||
return Ok(());
|
||||
}
|
||||
if app.input_content.is_empty() {
|
||||
app.message = Some("Content cannot be empty".to_string());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let client = app.get_client().await?;
|
||||
let visibility = if app.input_visibility.to_uppercase() == "PUBLIC" {
|
||||
Some("PUBLIC")
|
||||
} else {
|
||||
Some("PRIVATE")
|
||||
};
|
||||
|
||||
match client.create_memo(&app.input_content, visibility).await {
|
||||
Ok(memo) => {
|
||||
app.message = Some(format!("Memo created with ID: {}", memo.id));
|
||||
app.input_content.clear();
|
||||
app.input_visibility = "PRIVATE".to_string();
|
||||
app.mode = AppMode::MainMenu;
|
||||
}
|
||||
Err(e) => {
|
||||
app.message = Some(format!("Error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_list_memos_input(app: &mut App, key: &KeyEvent) -> Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Char('r') | KeyCode::Char('R') => {
|
||||
let client = app.get_client().await?;
|
||||
match client.list_memos(None, None).await {
|
||||
Ok(memos) => {
|
||||
app.memos = memos;
|
||||
app.message = Some("Memos refreshed".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
app.message = Some(format!("Error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !app.memos.is_empty() {
|
||||
if let Some(idx) = app.list_selected {
|
||||
if idx < app.memos.len() {
|
||||
app.selected_memo = Some(app.memos[idx].clone());
|
||||
app.mode = AppMode::ViewMemo;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if let Some(idx) = app.list_selected {
|
||||
if idx > 0 {
|
||||
app.list_selected = Some(idx - 1);
|
||||
}
|
||||
} else {
|
||||
app.list_selected = Some(0);
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if let Some(idx) = app.list_selected {
|
||||
if idx < app.memos.len() - 1 {
|
||||
app.list_selected = Some(idx + 1);
|
||||
}
|
||||
} else if !app.memos.is_empty() {
|
||||
app.list_selected = Some(0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
42
src/models.rs
Normal file
42
src/models.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateMemoRequest {
|
||||
pub content: String,
|
||||
#[serde(rename = "visibility")]
|
||||
pub visibility: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Memo {
|
||||
pub id: i64,
|
||||
pub creator_id: i64,
|
||||
pub created_ts: i64,
|
||||
pub updated_ts: i64,
|
||||
pub content: String,
|
||||
pub visibility: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub resources: Vec<Resource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Resource {
|
||||
pub id: i64,
|
||||
pub filename: String,
|
||||
#[serde(rename = "type")]
|
||||
pub resource_type: String,
|
||||
pub size: i64,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListMemosResponse {
|
||||
pub memos: Vec<Memo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateMemoResponse {
|
||||
pub id: i64,
|
||||
}
|
||||
351
src/tui.rs
Normal file
351
src/tui.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
use crate::config::Config;
|
||||
use crate::client::MemosClient;
|
||||
use crate::models::Memo;
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style, Stylize};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AppMode {
|
||||
MainMenu,
|
||||
Config,
|
||||
CreateMemo,
|
||||
ListMemos,
|
||||
ViewMemo,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub mode: AppMode,
|
||||
pub config: Config,
|
||||
pub memos: Vec<Memo>,
|
||||
pub selected_memo: Option<Memo>,
|
||||
pub input_content: String,
|
||||
pub input_visibility: String,
|
||||
pub input_base_url: String,
|
||||
pub input_token: String,
|
||||
pub current_field: usize,
|
||||
pub message: Option<String>,
|
||||
pub loading: bool,
|
||||
client: Arc<Mutex<Option<MemosClient>>>,
|
||||
pub list_selected: Option<usize>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self {
|
||||
mode: AppMode::MainMenu,
|
||||
config,
|
||||
memos: Vec::new(),
|
||||
selected_memo: None,
|
||||
input_content: String::new(),
|
||||
input_visibility: "PRIVATE".to_string(),
|
||||
input_base_url: String::new(),
|
||||
input_token: String::new(),
|
||||
current_field: 0,
|
||||
message: None,
|
||||
loading: false,
|
||||
client: Arc::new(Mutex::new(None)),
|
||||
list_selected: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn init_client(&self) -> Result<()> {
|
||||
if self.config.is_configured() {
|
||||
let client = MemosClient::new(&self.config.base_url, &self.config.user_token)?;
|
||||
let mut guard = self.client.lock().await;
|
||||
*guard = Some(client);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn refresh_client(&mut self) -> Result<()> {
|
||||
if self.config.is_configured() {
|
||||
let client = MemosClient::new(&self.config.base_url, &self.config.user_token)?;
|
||||
let mut guard = self.client.lock().await;
|
||||
*guard = Some(client);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_client(&self) -> Result<MemosClient> {
|
||||
let guard = self.client.lock().await;
|
||||
guard.clone().ok_or_else(|| anyhow::anyhow!("Client not initialized"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_app(frame: &mut Frame, app: &mut App) {
|
||||
match app.mode {
|
||||
AppMode::MainMenu => render_main_menu(frame, app),
|
||||
AppMode::Config => render_config(frame, app),
|
||||
AppMode::CreateMemo => render_create_memo(frame, app),
|
||||
AppMode::ListMemos => render_list_memos(frame, app),
|
||||
AppMode::ViewMemo => render_view_memo(frame, app),
|
||||
}
|
||||
|
||||
if let Some(msg) = &app.message {
|
||||
let area = Rect::new(10, frame.area().height - 3, frame.area().width - 10, frame.area().height - 1);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().fg(Color::Yellow));
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
let text = Paragraph::new(msg.as_str())
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(text, area);
|
||||
}
|
||||
|
||||
if app.loading {
|
||||
let area = Rect::new(
|
||||
frame.area().width / 2 - 10,
|
||||
frame.area().height / 2 - 3,
|
||||
frame.area().width / 2 + 10,
|
||||
frame.area().height / 2 + 3,
|
||||
);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Loading...")
|
||||
.title_alignment(Alignment::Center);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_main_menu(frame: &mut Frame, app: &App) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
let title = Paragraph::new("Memos CLI")
|
||||
.style(Style::default().fg(Color::Cyan).bold())
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::default().borders(Borders::ALL).title("Welcome"));
|
||||
frame.render_widget(title, chunks[0]);
|
||||
|
||||
let items = vec![
|
||||
("1", "List Memos"),
|
||||
("2", "Create Memo"),
|
||||
("3", "Configuration"),
|
||||
("Q", "Quit"),
|
||||
];
|
||||
|
||||
let menu_items: Vec<ListItem> = items
|
||||
.iter()
|
||||
.map(|(key, desc)| {
|
||||
let key_str = *key;
|
||||
let desc_str = *desc;
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::raw("["),
|
||||
Span::raw(key_str).style(Style::default().fg(Color::Yellow).bold()),
|
||||
Span::raw("] "),
|
||||
Span::raw(desc_str),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let menu = List::new(menu_items)
|
||||
.block(Block::default().borders(Borders::ALL).title("Menu"));
|
||||
frame.render_widget(menu, chunks[1]);
|
||||
|
||||
let status = if app.config.is_configured() {
|
||||
format!("Connected to: {}", app.config.base_url)
|
||||
} else {
|
||||
"Not configured".to_string()
|
||||
};
|
||||
let status_bar = Paragraph::new(status)
|
||||
.style(Style::default().fg(Color::Green))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(status_bar, chunks[2]);
|
||||
}
|
||||
|
||||
fn render_config(frame: &mut Frame, app: &mut App) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
let title = Paragraph::new("Configuration")
|
||||
.style(Style::default().fg(Color::Cyan).bold())
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::default().borders(Borders::ALL).title("Settings"));
|
||||
frame.render_widget(title, chunks[0]);
|
||||
|
||||
let base_url_label = Paragraph::new("Base URL:")
|
||||
.style(Style::default().fg(Color::White))
|
||||
.alignment(Alignment::Right);
|
||||
frame.render_widget(base_url_label, chunks[1]);
|
||||
|
||||
let base_url_input = Paragraph::new(app.input_base_url.clone())
|
||||
.style(Style::default().fg(Color::Yellow))
|
||||
.block(Block::default().borders(Borders::ALL).title("Base URL"));
|
||||
frame.render_widget(base_url_input, chunks[1]);
|
||||
|
||||
let token_label = Paragraph::new("User Token:")
|
||||
.style(Style::default().fg(Color::White))
|
||||
.alignment(Alignment::Right);
|
||||
frame.render_widget(token_label, chunks[2]);
|
||||
|
||||
let token_input = Paragraph::new(app.input_token.clone())
|
||||
.style(Style::default().fg(Color::Yellow))
|
||||
.block(Block::default().borders(Borders::ALL).title("Token"));
|
||||
frame.render_widget(token_input, chunks[2]);
|
||||
|
||||
let help = Paragraph::new("Enter: Save | Esc: Cancel | Tab: Switch Field")
|
||||
.style(Style::default().fg(Color::DarkGray))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(help, chunks[5]);
|
||||
}
|
||||
|
||||
fn render_create_memo(frame: &mut Frame, app: &mut App) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
let title = Paragraph::new("Create Memo")
|
||||
.style(Style::default().fg(Color::Cyan).bold())
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::default().borders(Borders::ALL).title("New Memo"));
|
||||
frame.render_widget(title, chunks[0]);
|
||||
|
||||
let visibility_label = Paragraph::new("Visibility (PUBLIC/PRIVATE):")
|
||||
.style(Style::default().fg(Color::White));
|
||||
frame.render_widget(visibility_label, chunks[1]);
|
||||
|
||||
let visibility_input = Paragraph::new(app.input_visibility.clone())
|
||||
.style(Style::default().fg(Color::Yellow))
|
||||
.block(Block::default().borders(Borders::ALL).title("Visibility"));
|
||||
frame.render_widget(visibility_input, chunks[1]);
|
||||
|
||||
let content_label = Paragraph::new("Content:")
|
||||
.style(Style::default().fg(Color::White));
|
||||
frame.render_widget(content_label, chunks[2]);
|
||||
|
||||
let content = Paragraph::new(app.input_content.clone())
|
||||
.style(Style::default().fg(Color::Yellow))
|
||||
.block(Block::default().borders(Borders::ALL).title("Content"));
|
||||
frame.render_widget(content, chunks[2]);
|
||||
|
||||
let help = Paragraph::new("Enter: Submit | Esc: Cancel")
|
||||
.style(Style::default().fg(Color::DarkGray))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(help, chunks[3]);
|
||||
}
|
||||
|
||||
fn render_list_memos(frame: &mut Frame, app: &mut App) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
let title = Paragraph::new("Memos List")
|
||||
.style(Style::default().fg(Color::Cyan).bold())
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::default().borders(Borders::ALL).title("Memos"));
|
||||
frame.render_widget(title, chunks[0]);
|
||||
|
||||
let memos_list: Vec<ListItem> = app.memos.iter().enumerate().map(|(idx, memo)| {
|
||||
let content_preview = if memo.content.len() > 50 {
|
||||
format!("{}...", &memo.content[..50])
|
||||
} else {
|
||||
memo.content.clone()
|
||||
};
|
||||
let created = chrono::DateTime::from_timestamp_millis(memo.created_ts)
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
let style = if app.list_selected == Some(idx) {
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::raw(format!("[{}] ", memo.id)),
|
||||
Span::styled(content_preview, Style::default().fg(Color::White)),
|
||||
Span::raw(" - ").style(Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(created).style(Style::default().fg(Color::Green)),
|
||||
])).style(style)
|
||||
}).collect();
|
||||
|
||||
let list = List::new(memos_list)
|
||||
.block(Block::default().borders(Borders::ALL));
|
||||
frame.render_widget(list, chunks[1]);
|
||||
|
||||
let help = Paragraph::new("Enter: View | R: Refresh | Esc: Back | ↑↓: Navigate")
|
||||
.style(Style::default().fg(Color::DarkGray))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(help, chunks[2]);
|
||||
}
|
||||
|
||||
fn render_view_memo(frame: &mut Frame, app: &App) {
|
||||
if let Some(memo) = &app.selected_memo {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
let title = Paragraph::new(format!("Memo #{}", memo.id))
|
||||
.style(Style::default().fg(Color::Cyan).bold())
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::default().borders(Borders::ALL).title("View Memo"));
|
||||
frame.render_widget(title, chunks[0]);
|
||||
|
||||
let meta = format!(
|
||||
"Created: {} | Updated: {} | Visibility: {}",
|
||||
chrono::DateTime::from_timestamp_millis(memo.created_ts)
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
chrono::DateTime::from_timestamp_millis(memo.updated_ts)
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
memo.visibility
|
||||
);
|
||||
let meta_para = Paragraph::new(meta)
|
||||
.style(Style::default().fg(Color::Green))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(meta_para, chunks[1]);
|
||||
|
||||
let content = Paragraph::new(memo.content.clone())
|
||||
.style(Style::default().fg(Color::White))
|
||||
.block(Block::default().borders(Borders::ALL).title("Content"));
|
||||
frame.render_widget(content, chunks[2]);
|
||||
|
||||
let help = Paragraph::new("Esc: Back")
|
||||
.style(Style::default().fg(Color::DarkGray))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(help, chunks[3]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user