From 3271283c82029965e164e188bf7fb197f5edab3e Mon Sep 17 00:00:00 2001 From: SidneyZhang Date: Wed, 15 Apr 2026 16:37:47 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=AE=8C=E5=96=84=20README=20=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=8F=AF=E8=A7=81=E6=80=A7=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 141 ++++++++++++++- src/client.rs | 48 +++-- src/main.rs | 473 ++++++++++++++++++++++++++++++-------------------- src/models.rs | 84 +++++++-- src/tui.rs | 267 ++++++++++++++++++++-------- 5 files changed, 717 insertions(+), 296 deletions(-) diff --git a/README.md b/README.md index a54fa30..1e82922 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,143 @@ [![Built With Ratatui](https://img.shields.io/badge/Built_With_Ratatui-000?logo=ratatui&logoColor=fff)](https://ratatui.rs/) -使用命令行管理自己部署的Memos。 +使用命令行管理自己部署的 [Memos](https://usememos.com/)。 -[Memos](https://usememos.com/) +## 功能特性 + +- **TUI 交互界面**:基于 Ratatui 的终端用户界面,支持键盘导航 +- **CLI 命令行模式**:支持命令行直接操作,便于脚本集成 +- **Memo 管理**:创建、查看、列出 Memos +- **多可见性选项**:支持 PRIVATE、PUBLIC、PROTECTED 等可见性设置 +- **分页浏览**:支持分页查看 Memos 列表 +- **配置持久化**:自动保存服务器地址和 Token 配置 + +## 安装 + +### 从源码编译 + +```bash +git clone https://github.com/your-repo/memoscli.git +cd memoscli +cargo build --release +``` + +编译后的二进制文件位于 `target/release/memoscli`。 + +## 使用方法 + +### TUI 模式(默认) + +直接运行程序进入 TUI 交互界面: + +```bash +memoscli +``` + +#### 主菜单操作 + +| 按键 | 功能 | +|------|------| +| `↑` / `↓` | 导航菜单 | +| `Enter` | 选择菜单项 | +| `Q` | 退出程序 | + +#### 创建 Memo + +| 按键 | 功能 | +|------|------| +| `i` | 进入编辑模式 | +| `v` | 选择可见性 | +| `/` | 显示帮助 | +| `Enter` | 提交 Memo | +| `Esc` | 返回主菜单 | + +编辑模式下: + +| 按键 | 功能 | +|------|------| +| `Enter` | 换行 | +| `Esc` | 退出编辑模式 | + +#### 查看 Memos 列表 + +| 按键 | 功能 | +|------|------| +| `↑` / `↓` | 选择 Memo | +| `←` / `→` | 翻页 | +| `Enter` | 查看详情 | +| `R` | 刷新列表 | +| `Esc` | 返回主菜单 | + +#### 配置设置 + +| 按键 | 功能 | +|------|------| +| `Tab` | 切换输入字段 | +| `Enter` | 保存配置 | +| `Esc` | 取消并返回 | + +### CLI 命令行模式 + +#### 配置 + +```bash +# 设置服务器地址和 Token +memoscli config --base-url https://your-memos-server.com --token your-token + +# 查看当前配置 +memoscli config --show +``` + +#### 创建 Memo + +```bash +# 直接输入内容 +memoscli create --content "Hello, Memos!" + +# 从文件读取内容 +memoscli create --file memo.md + +# 指定可见性 +memoscli create --content "Public memo" --visibility PUBLIC +``` + +#### 列出 Memos + +```bash +# 列出最近的 Memos +memoscli list + +# 指定数量和偏移 +memoscli list --limit 50 --offset 10 +``` + +## 配置文件 + +配置文件存储位置: + +- **Linux/macOS**: `~/.config/memoscli/config.json` +- **Windows**: `%APPDATA%\memoscli\config\config.json` + +配置文件格式: + +```json +{ + "base_url": "https://your-memos-server.com", + "user_token": "your-api-token" +} +``` + +## 依赖 + +- [ratatui](https://ratatui.rs/) - 终端 UI 框架 +- [crossterm](https://github.com/crossterm-rs/crossterm) - 终端操作库 +- [reqwest](https://github.com/seanmonstar/reqwest) - HTTP 客户端 +- [tokio](https://tokio.rs/) - 异步运行时 +- [clap](https://github.com/clap-rs/clap) - 命令行参数解析 +- [chrono](https://github.com/chronotope/chrono) - 日期时间处理 +- [anyhow](https://github.com/dtolnay/anyhow) - 错误处理 + +## 许可证 + +MIT License diff --git a/src/client.rs b/src/client.rs index 790602c..3127c5c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,4 +1,4 @@ -use crate::models::{CreateMemoRequest, CreateMemoResponse, ListMemosResponse, Memo}; +use crate::models::{CreateMemoRequest, ListMemosResponse, Memo}; use anyhow::Result; use reqwest::Client; @@ -21,13 +21,22 @@ impl MemosClient { pub async fn create_memo(&self, content: &str, visibility: Option<&str>) -> Result { let url = format!("{}/api/v1/memos", self.base_url); - + + let visibility_str = visibility.map(|v| { + if v.starts_with("VISIBILITY_") { + v.to_string() + } else { + format!("VISIBILITY_{}", v.to_uppercase()) + } + }); + let request = CreateMemoRequest { content: content.to_string(), - visibility: visibility.map(|s| s.to_string()), + visibility: visibility_str, }; - let response = self.client + let response = self + .client .post(&url) .header("Authorization", format!("Bearer {}", self.user_token)) .header("Content-Type", "application/json") @@ -41,33 +50,16 @@ impl MemosClient { 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 { - 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?) + let mut memo: Memo = response.json().await?; + memo.id = Memo::extract_id(&memo.name); + Ok(memo) } pub async fn list_memos(&self, limit: Option, offset: Option) -> Result> { let url = format!("{}/api/v1/memos", self.base_url); - - let mut request = self.client + + let mut request = self + .client .get(&url) .header("Authorization", format!("Bearer {}", self.user_token)); @@ -89,4 +81,4 @@ impl MemosClient { let result: ListMemosResponse = response.json().await?; Ok(result.memos) } -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index ee4cb34..d419dbb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,9 @@ 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 crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, +}; use models::Memo; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; @@ -21,24 +23,24 @@ 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 { @@ -53,13 +55,13 @@ async fn run_cli_command(command: Command) -> Result<()> { 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); @@ -69,26 +71,29 @@ async fn run_cli_command(command: Command) -> Result<()> { } } } - 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()); + 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)] + ); } - } - Err(e) => { - println!("Error listing memos: {}", e); + println!("\nTotal: {} memos", memos.len()); } } - } + Err(e) => { + println!("Error listing memos: {}", e); + } + }, Command::Config(cmd) => { if cmd.show { if config.is_configured() { @@ -110,7 +115,7 @@ async fn run_cli_command(command: Command) -> Result<()> { } } } - + Ok(()) } @@ -130,163 +135,206 @@ async fn run_tui_mode() -> Result<()> { 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; + let event = event::read()?; + match event { + Event::Paste(text) => match app.mode { + AppMode::Config => { + if app.current_field == 0 { + app.input_base_url.push_str(&text); + } else { + app.input_token.push_str(&text); } - - if key.code == KeyCode::Esc && !app.loading { - if app.mode == AppMode::CreateMemo && app.is_editing { - app.is_editing = false; - continue; - } - 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.is_editing = false; - app.mode = AppMode::MainMenu; - } - AppMode::ListMemos => { - app.mode = AppMode::MainMenu; - } - AppMode::ViewMemo => { - app.mode = AppMode::ListMemos; - } - AppMode::SelectVisibility => { - app.mode = AppMode::CreateMemo; - } - AppMode::Help => { - app.mode = AppMode::CreateMemo; - } - } - app.message = None; - } - - match app.mode { - AppMode::MainMenu => { - match key.code { - KeyCode::Up => { - if let Some(selected) = app.list_state.selected() { - if selected > 0 { - app.list_state.select(Some(selected - 1)); - } - } else { - app.list_state.select(Some(0)); - } - } - KeyCode::Down => { - if let Some(selected) = app.list_state.selected() { - if selected < 3 { // 4 items, 0-3 index - app.list_state.select(Some(selected + 1)); - } - } else { - app.list_state.select(Some(0)); - } - } - KeyCode::Enter => { - if let Some(selected) = app.list_state.selected() { - match selected { - 0 => { // List Memos - 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()); - } - } - 1 => { // Create Memo - if app.config.is_configured() { - app.input_content.clear(); - app.input_visibility = "PRIVATE".to_string(); - app.is_editing = false; - app.mode = AppMode::CreateMemo; - } else { - app.message = Some("Please configure first".to_string()); - } - } - 2 => { // Configuration - app.input_base_url = app.config.base_url.clone(); - app.input_token = app.config.user_token.clone(); - app.mode = AppMode::Config; - } - 3 => { // Quit - break; - } - _ => {} - } - } - } - 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 => {} - AppMode::SelectVisibility => { - handle_select_visibility_input(&mut app, &key).await?; - } - AppMode::Help => { - // 处理帮助模式下的滚动 - match key.code { - KeyCode::Up => { - if app.help_scroll > 0 { - app.help_scroll -= 1; - } - } - KeyCode::Down => { - // 帮助信息有 12 行,弹窗内容区域高度为 11 行 - if app.help_scroll < 12 - 11 { // 12 是帮助信息行数,11 是弹窗内容区域高度 - app.help_scroll += 1; - } - } - _ => { - // 按下其他键关闭帮助弹窗,返回 CreateMemo 模式 - app.mode = AppMode::CreateMemo; - } - } - } + } + AppMode::CreateMemo => { + if app.is_editing { + app.input_content.push_str(&text); } } _ => {} + }, + Event::Key(key) => { + 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 { + if app.mode == AppMode::CreateMemo && app.is_editing { + app.is_editing = false; + continue; + } + 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.is_editing = false; + app.mode = AppMode::MainMenu; + } + AppMode::ListMemos => { + app.mode = AppMode::MainMenu; + } + AppMode::ViewMemo => { + app.mode = AppMode::ListMemos; + } + AppMode::SelectVisibility => { + app.mode = AppMode::CreateMemo; + } + AppMode::Help => { + app.mode = AppMode::CreateMemo; + } + } + app.message = None; + } + + match app.mode { + AppMode::MainMenu => { + match key.code { + KeyCode::Up => { + if let Some(selected) = app.list_state.selected() { + if selected > 0 { + app.list_state.select(Some(selected - 1)); + } + } else { + app.list_state.select(Some(0)); + } + } + KeyCode::Down => { + if let Some(selected) = app.list_state.selected() { + if selected < 3 { + // 4 items, 0-3 index + app.list_state.select(Some(selected + 1)); + } + } else { + app.list_state.select(Some(0)); + } + } + KeyCode::Enter => { + if let Some(selected) = app.list_state.selected() { + match selected { + 0 => { + // List Memos + 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.current_page = 0; + app.list_selected = Some(0); + 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(), + ); + } + } + 1 => { + // Create Memo + if app.config.is_configured() { + app.input_content.clear(); + app.input_visibility = + "PRIVATE".to_string(); + app.is_editing = false; + app.mode = AppMode::CreateMemo; + } else { + app.message = Some( + "Please configure first".to_string(), + ); + } + } + 2 => { + // Configuration + app.input_base_url = + app.config.base_url.clone(); + app.input_token = app.config.user_token.clone(); + app.mode = AppMode::Config; + } + 3 => { + // Quit + break; + } + _ => {} + } + } + } + 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 => { + handle_view_memo_input(&mut app, &key).await?; + } + AppMode::SelectVisibility => { + handle_select_visibility_input(&mut app, &key).await?; + } + AppMode::Help => { + // 处理帮助模式下的滚动 + match key.code { + KeyCode::Up => { + if app.help_scroll > 0 { + app.help_scroll -= 1; + } + } + KeyCode::Down => { + // 帮助信息有 12 行,弹窗内容区域高度为 11 行 + if app.help_scroll < 12 - 11 { + // 12 是帮助信息行数,11 是弹窗内容区域高度 + app.help_scroll += 1; + } + } + _ => { + // 按下其他键关闭帮助弹窗,返回 CreateMemo 模式 + app.mode = AppMode::CreateMemo; + } + } + } + } + } + _ => {} + } } + _ => {} } } disable_raw_mode()?; + execute!(terminal.backend_mut(), Clear(ClearType::All))?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; Ok(()) } @@ -398,12 +446,22 @@ async fn handle_create_memo_input(app: &mut App, key: &KeyEvent) -> Result<()> { } async fn handle_list_memos_input(app: &mut App, key: &KeyEvent) -> Result<()> { + let total_items = app.memos.len(); + let items_per_page = app.items_per_page; + let total_pages = if total_items == 0 { + 1 + } else { + (total_items + items_per_page - 1) / items_per_page + }; + 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.current_page = 0; + app.list_selected = Some(0); app.message = Some("Memos refreshed".to_string()); } Err(e) => { @@ -412,33 +470,59 @@ async fn handle_list_memos_input(app: &mut App, key: &KeyEvent) -> Result<()> { } } KeyCode::Enter => { + app.message = None; 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.memo_content_scroll = 0; app.mode = AppMode::ViewMemo; } } } } KeyCode::Up => { + app.message = None; if let Some(idx) = app.list_selected { if idx > 0 { - app.list_selected = Some(idx - 1); + let new_idx = idx - 1; + app.list_selected = Some(new_idx); + if new_idx < app.current_page * items_per_page { + app.current_page = new_idx / items_per_page; + } } } else { app.list_selected = Some(0); } } KeyCode::Down => { + app.message = None; if let Some(idx) = app.list_selected { - if idx < app.memos.len() - 1 { - app.list_selected = Some(idx + 1); + if idx < app.memos.len().saturating_sub(1) { + let new_idx = idx + 1; + app.list_selected = Some(new_idx); + if new_idx >= (app.current_page + 1) * items_per_page { + app.current_page = new_idx / items_per_page; + } } } else if !app.memos.is_empty() { app.list_selected = Some(0); } } + KeyCode::Left => { + app.message = None; + if app.current_page > 0 { + app.current_page -= 1; + app.list_selected = Some(app.current_page * items_per_page); + } + } + KeyCode::Right => { + app.message = None; + if app.current_page < total_pages.saturating_sub(1) { + app.current_page += 1; + app.list_selected = Some(app.current_page * items_per_page); + } + } _ => {} } Ok(()) @@ -457,7 +541,8 @@ async fn handle_select_visibility_input(app: &mut App, key: &KeyEvent) -> Result } KeyCode::Down => { if let Some(selected) = app.visibility_list_state.selected() { - if selected < 3 { // 4 items, 0-3 index + if selected < 3 { + // 4 items, 0-3 index app.visibility_list_state.select(Some(selected + 1)); } } else { @@ -465,12 +550,8 @@ async fn handle_select_visibility_input(app: &mut App, key: &KeyEvent) -> Result } } KeyCode::Enter => { - let visibility_options = vec![ - "VISIBILITY_UNSPECIFIED", - "PRIVATE", - "PROTECTED", - "PUBLIC", - ]; + let visibility_options = + vec!["VISIBILITY_UNSPECIFIED", "PRIVATE", "PROTECTED", "PUBLIC"]; if let Some(selected) = app.visibility_list_state.selected() { if selected < visibility_options.len() { app.input_visibility = visibility_options[selected].to_string(); @@ -484,4 +565,26 @@ async fn handle_select_visibility_input(app: &mut App, key: &KeyEvent) -> Result _ => {} } Ok(()) -} \ No newline at end of file +} + +async fn handle_view_memo_input(app: &mut App, key: &KeyEvent) -> Result<()> { + match key.code { + KeyCode::Up => { + if app.memo_content_scroll > 0 { + app.memo_content_scroll -= 1; + } + } + KeyCode::Down => { + if let Some(memo) = &app.selected_memo { + let content_width = 80; + let wrapped_lines = tui::wrap_text(&memo.content, content_width); + let total_lines = wrapped_lines.len(); + if app.memo_content_scroll < total_lines.saturating_sub(1) { + app.memo_content_scroll += 1; + } + } + } + _ => {} + } + Ok(()) +} diff --git a/src/models.rs b/src/models.rs index fec6afc..29bfb98 100644 --- a/src/models.rs +++ b/src/models.rs @@ -3,40 +3,100 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateMemoRequest { pub content: String, - #[serde(rename = "visibility")] + #[serde(skip_serializing_if = "Option::is_none")] pub visibility: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Memo { + #[serde(skip)] pub id: i64, - pub creator_id: i64, + pub name: String, + #[serde(default)] + pub state: String, + pub creator: String, + #[serde(rename = "createTime", deserialize_with = "parse_timestamp")] pub created_ts: i64, + #[serde(rename = "updateTime", deserialize_with = "parse_timestamp", default)] pub updated_ts: i64, + #[serde(default)] pub content: String, + #[serde(default)] pub visibility: String, #[serde(default)] pub tags: Vec, #[serde(default)] - pub resources: Vec, + pub attachments: Vec, + #[serde(default)] + pub pinned: bool, +} + +impl Memo { + pub fn extract_id(name: &str) -> i64 { + name.rsplit('/') + .next() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) + } +} + +fn parse_timestamp<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde_json::Value; + let value = Value::deserialize(deserializer)?; + + match value { + Value::String(s) => { + if s.is_empty() { + return Ok(0); + } + chrono::DateTime::parse_from_rfc3339(&s) + .map(|dt| dt.timestamp_millis()) + .or_else(|_| { + s.parse::() + .map(|ts| if ts < 10000000000 { ts * 1000 } else { ts }) + }) + .map_err(|_| serde::de::Error::custom(format!("Invalid timestamp format: {}", s))) + } + Value::Number(n) => { + let ts = n.as_i64().unwrap_or(0); + Ok(if ts < 10000000000 { ts * 1000 } else { ts }) + } + Value::Null => Ok(0), + _ => Err(serde::de::Error::custom("Invalid timestamp type")), + } } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Resource { - pub id: i64, +pub struct Attachment { + pub name: String, + #[serde(default)] pub filename: String, - #[serde(rename = "type")] - pub resource_type: String, - pub size: i64, - pub url: String, + #[serde(rename = "type", default)] + pub attachment_type: String, + #[serde(default)] + pub size: String, + #[serde(default)] + pub external_link: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListMemosResponse { + #[serde(deserialize_with = "deserialize_memos")] pub memos: Vec, + #[serde(default)] + pub next_page_token: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CreateMemoResponse { - pub id: i64, +fn deserialize_memos<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let mut memos: Vec = Vec::deserialize(deserializer)?; + for memo in &mut memos { + memo.id = Memo::extract_id(&memo.name); + } + Ok(memos) } diff --git a/src/tui.rs b/src/tui.rs index 2d08fa1..37bd69c 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,15 +1,61 @@ -use crate::config::Config; use crate::client::MemosClient; +use crate::config::Config; use crate::models::Memo; use anyhow::Result; +use chrono::Local; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListDirection, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}; +use ratatui::widgets::{ + Block, Borders, Clear, List, ListDirection, ListItem, ListState, Paragraph, Scrollbar, + ScrollbarOrientation, ScrollbarState, +}; use ratatui::Frame; use std::sync::Arc; use tokio::sync::Mutex; -use chrono::Local; + +pub fn wrap_text(text: &str, width: usize) -> Vec { + if width == 0 { + return vec![text.to_string()]; + } + + let mut lines = Vec::new(); + + for line in text.lines() { + if line.is_empty() { + lines.push(String::new()); + continue; + } + + let mut current_line = String::new(); + let mut current_width = 0; + + for ch in line.chars() { + let char_width = if ch.is_ascii() { 1 } else { 2 }; + + if current_width + char_width > width { + if !current_line.is_empty() { + lines.push(current_line.clone()); + } + current_line = ch.to_string(); + current_width = char_width; + } else { + current_line.push(ch); + current_width += char_width; + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + } + + if lines.is_empty() { + lines.push(String::new()); + } + + lines +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppMode { @@ -40,8 +86,12 @@ pub struct App { pub visibility_list_state: ListState, pub content_scroll: usize, pub help_scroll: usize, + pub memo_content_scroll: usize, + pub memo_scrollbar_state: ScrollbarState, pub scrollbar_state: ScrollbarState, pub is_editing: bool, + pub current_page: usize, + pub items_per_page: usize, } impl App { @@ -68,8 +118,12 @@ impl App { visibility_list_state, content_scroll: 0usize, help_scroll: 0usize, + memo_content_scroll: 0usize, + memo_scrollbar_state: ScrollbarState::default(), scrollbar_state: ScrollbarState::default(), is_editing: false, + current_page: 0, + items_per_page: 10, } } @@ -93,7 +147,9 @@ impl App { pub async fn get_client(&self) -> Result { let guard = self.client.lock().await; - guard.clone().ok_or_else(|| anyhow::anyhow!("Client not initialized")) + guard + .clone() + .ok_or_else(|| anyhow::anyhow!("Client not initialized")) } } @@ -118,8 +174,7 @@ pub fn render_app(frame: &mut Frame, app: &mut App) { .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); + let text = Paragraph::new(msg.as_str()).alignment(Alignment::Center); frame.render_widget(text, area); } } @@ -184,8 +239,8 @@ fn render_main_menu(frame: &mut Frame, app: &mut App) { // 数字时钟 let current_time = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); - let clock = Paragraph::new(format!("🕒 {}", current_time)) - .style(Style::default().fg(Color::Yellow)); + let clock = + Paragraph::new(format!("🕒 {}", current_time)).style(Style::default().fg(Color::Yellow)); frame.render_widget(clock, chunks[3]); // 功能选择 @@ -285,8 +340,8 @@ fn render_create_memo(frame: &mut Frame, app: &mut App) { .block(Block::default().borders(Borders::ALL)); frame.render_widget(title, chunks[0]); - let visibility_label = Paragraph::new("Visibility (PUBLIC/PRIVATE):") - .style(Style::default().fg(Color::White)); + 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()) @@ -294,8 +349,7 @@ fn render_create_memo(frame: &mut Frame, app: &mut App) { .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)); + let content_label = Paragraph::new("Content:").style(Style::default().fg(Color::White)); frame.render_widget(content_label, chunks[2]); let content_title = if app.is_editing { @@ -331,47 +385,90 @@ fn render_list_memos(frame: &mut Frame, app: &mut App) { ]) .split(frame.area()); - let title = Paragraph::new("Memos List") + let total_items = app.memos.len(); + let available_height = chunks[1].height.saturating_sub(2) as usize; + app.items_per_page = if available_height > 0 { + available_height + } else { + 10 + }; + + let total_pages = if total_items == 0 { + 1 + } else { + (total_items + app.items_per_page - 1) / app.items_per_page + }; + + if app.current_page >= total_pages { + app.current_page = total_pages.saturating_sub(1); + } + + let start_idx = app.current_page * app.items_per_page; + let end_idx = std::cmp::min(start_idx + app.items_per_page, total_items); + + let page_info = format!( + "Memos List - Page {}/{} ({} items)", + app.current_page + 1, + total_pages, + total_items + ); + + let title = Paragraph::new(page_info) .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 = 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(); + if app.memos.is_empty() { + let empty_msg = Paragraph::new("No memos found") + .style(Style::default().fg(Color::Yellow)) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(empty_msg, chunks[1]); + } else { + let memos_list: Vec = app.memos[start_idx..end_idx] + .iter() + .enumerate() + .map(|(idx, memo)| { + let actual_idx = start_idx + idx; + let content_preview = if memo.content.chars().count() > 50 { + format!("{}...", memo.content.chars().take(50).collect::()) + } 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 list = List::new(memos_list) - .block(Block::default().borders(Borders::ALL)); - frame.render_widget(list, chunks[1]); + let style = if app.list_selected == Some(actual_idx) { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; - let help = Paragraph::new("Enter: View | R: Refresh | Esc: Back | ↑↓: Navigate") + 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 | ←→: Page | 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) { +fn render_view_memo(frame: &mut Frame, app: &mut App) { if let Some(memo) = &app.selected_memo { let chunks = Layout::default() .direction(Direction::Vertical) @@ -404,12 +501,49 @@ fn render_view_memo(frame: &mut Frame, app: &App) { .alignment(Alignment::Center); frame.render_widget(meta_para, chunks[1]); - let content = Paragraph::new(memo.content.clone()) + let content_width = chunks[2].width.saturating_sub(2) as usize; + let wrapped_lines = wrap_text(&memo.content, content_width); + let total_lines = wrapped_lines.len(); + + let content_lines: Vec = wrapped_lines + .iter() + .map(|line| Line::from(line.clone())) + .collect(); + + let viewport_height = chunks[2].height.saturating_sub(2) as usize; + + if app.memo_content_scroll >= total_lines { + app.memo_content_scroll = total_lines.saturating_sub(1); + } + + let content = Paragraph::new(content_lines) .style(Style::default().fg(Color::White)) - .block(Block::default().borders(Borders::ALL).title("Content")); + .block(Block::default().borders(Borders::ALL).title("Content")) + .scroll((app.memo_content_scroll as u16, 0)); frame.render_widget(content, chunks[2]); - let help = Paragraph::new("Esc: Back") + let scrollbar_area = Rect::new( + chunks[2].x + chunks[2].width - 1, + chunks[2].y + 1, + 1, + chunks[2].height.saturating_sub(2), + ); + + app.memo_scrollbar_state = ScrollbarState::default() + .position(app.memo_content_scroll) + .content_length(total_lines) + .viewport_content_length(viewport_height); + + frame.render_stateful_widget( + Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .thumb_style(Style::default().bg(Color::Cyan)) + .track_style(Style::default().bg(Color::DarkGray)), + scrollbar_area, + &mut app.memo_scrollbar_state, + ); + + let help = Paragraph::new("↑↓: Scroll | Esc: Back") .style(Style::default().fg(Color::DarkGray)) .alignment(Alignment::Center); frame.render_widget(help, chunks[3]); @@ -423,43 +557,38 @@ fn render_select_visibility(frame: &mut Frame, app: &mut App) { let x = (frame.area().width / 2).saturating_sub(width / 2); let y = (frame.area().height / 2).saturating_sub(height / 2); let area = Rect::new(x, y, width, height); - + // 清除弹窗区域 frame.render_widget(Clear, area); - + // 渲染弹窗边框 let block = Block::default() .borders(Borders::ALL) .title("Select Visibility") .title_alignment(Alignment::Center); frame.render_widget(block, area); - + // 定义 Visibility 选项 - let visibility_options = vec![ - "VISIBILITY_UNSPECIFIED", - "PRIVATE", - "PROTECTED", - "PUBLIC", - ]; - + let visibility_options = vec!["VISIBILITY_UNSPECIFIED", "PRIVATE", "PROTECTED", "PUBLIC"]; + // 创建列表项 let list_items: Vec = visibility_options .iter() .map(|option| ListItem::new(*option)) .collect(); - + // 创建列表 let list = List::new(list_items) .block(Block::default()) .highlight_style(Style::default().bg(Color::DarkGray).fg(Color::Cyan).bold()) .direction(ListDirection::TopToBottom); - + // 创建列表区域(留出标题栏和边框的空间) let list_area = Rect::new(x + 2, y + 2, width - 4, height - 4); - + // 渲染列表 frame.render_stateful_widget(list, list_area, &mut app.visibility_list_state); - + // 渲染帮助信息 let help = Paragraph::new("↑↓: Navigate | Enter: Select | Esc: Cancel") .style(Style::default().fg(Color::DarkGray)) @@ -475,17 +604,17 @@ fn render_help(frame: &mut Frame, app: &mut App) { let x = (frame.area().width / 2).saturating_sub(width / 2); let y = (frame.area().height / 2).saturating_sub(height / 2); let area = Rect::new(x, y, width, height); - + // 清除弹窗区域 frame.render_widget(Clear, area); - + // 渲染弹窗边框 let block = Block::default() .borders(Borders::ALL) .title("HELP") .title_alignment(Alignment::Center); frame.render_widget(block, area); - + // 帮助信息 let help_lines = vec![ Line::from("Input Help:"), @@ -501,27 +630,27 @@ fn render_help(frame: &mut Frame, app: &mut App) { Line::from("- PUBLIC: Visible to everyone"), Line::from("- PROTECTED: Visible to shared users"), ]; - + // 创建帮助信息区域(留出标题栏、边框和滚动条的空间) let help_area = Rect::new(x + 2, y + 2, width - 6, height - 4); - + // 创建滚动条区域 let scrollbar_area = Rect::new(x + width - 4, y + 2, 1, height - 4); - + // 创建帮助信息段落 let help_text = Paragraph::new(help_lines.clone()) .style(Style::default().fg(Color::White)) .scroll((app.help_scroll.try_into().unwrap(), 0)); - + // 渲染帮助信息 frame.render_widget(help_text, help_area); - + // 更新滚动条状态 app.scrollbar_state = ScrollbarState::default() .position(app.help_scroll) .content_length(help_lines.len()) .viewport_content_length((height - 4) as usize); - + frame.render_stateful_widget( Scrollbar::default() .orientation(ScrollbarOrientation::VerticalRight) @@ -530,11 +659,11 @@ fn render_help(frame: &mut Frame, app: &mut App) { scrollbar_area, &mut app.scrollbar_state, ); - + // 渲染底部提示 let hint = Paragraph::new("Press any key to close | ↑↓: Scroll") .style(Style::default().fg(Color::DarkGray)) .alignment(Alignment::Center); let hint_area = Rect::new(x, y + height, width, 2); frame.render_widget(hint, hint_area); -} \ No newline at end of file +}