docs: 完善 README 并补充可见性配置说明

This commit is contained in:
2026-04-15 16:37:47 +08:00
parent cf13e516b9
commit 3271283c82
5 changed files with 717 additions and 296 deletions

141
README.md
View File

@@ -2,6 +2,143 @@
[![Built With Ratatui](https://img.shields.io/badge/Built_With_Ratatui-000?logo=ratatui&logoColor=fff)](https://ratatui.rs/) [![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

View File

@@ -1,4 +1,4 @@
use crate::models::{CreateMemoRequest, CreateMemoResponse, ListMemosResponse, Memo}; use crate::models::{CreateMemoRequest, ListMemosResponse, Memo};
use anyhow::Result; use anyhow::Result;
use reqwest::Client; use reqwest::Client;
@@ -22,12 +22,21 @@ impl MemosClient {
pub async fn create_memo(&self, content: &str, visibility: Option<&str>) -> Result<Memo> { pub async fn create_memo(&self, content: &str, visibility: Option<&str>) -> Result<Memo> {
let url = format!("{}/api/v1/memos", self.base_url); 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 { let request = CreateMemoRequest {
content: content.to_string(), content: content.to_string(),
visibility: visibility.map(|s| s.to_string()), visibility: visibility_str,
}; };
let response = self.client let response = self
.client
.post(&url) .post(&url)
.header("Authorization", format!("Bearer {}", self.user_token)) .header("Authorization", format!("Bearer {}", self.user_token))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
@@ -41,33 +50,16 @@ impl MemosClient {
anyhow::bail!("Failed to create memo: {} - {}", status, error_text); anyhow::bail!("Failed to create memo: {} - {}", status, error_text);
} }
let result: CreateMemoResponse = response.json().await?; let mut memo: Memo = response.json().await?;
memo.id = Memo::extract_id(&memo.name);
self.get_memo(result.id).await Ok(memo)
}
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>> { 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 url = format!("{}/api/v1/memos", self.base_url);
let mut request = self.client let mut request = self
.client
.get(&url) .get(&url)
.header("Authorization", format!("Bearer {}", self.user_token)); .header("Authorization", format!("Bearer {}", self.user_token));

View File

@@ -11,7 +11,9 @@ use client::MemosClient;
use config::Config; use config::Config;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use crossterm::execute; 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 models::Memo;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::Terminal; use ratatui::Terminal;
@@ -69,26 +71,29 @@ async fn run_cli_command(command: Command) -> Result<()> {
} }
} }
} }
Command::List(cmd) => { Command::List(cmd) => match client.list_memos(Some(cmd.limit), Some(cmd.offset)).await {
match client.list_memos(Some(cmd.limit), Some(cmd.offset)).await { Ok(memos) => {
Ok(memos) => { if memos.is_empty() {
if memos.is_empty() { println!("No memos found.");
println!("No memos found."); } else {
} else { for memo in &memos {
for memo in &memos { let created = chrono::DateTime::from_timestamp_millis(memo.created_ts)
let created = chrono::DateTime::from_timestamp_millis(memo.created_ts) .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_else(|| "Unknown".to_string());
.unwrap_or_else(|| "Unknown".to_string()); println!(
println!("[{}] {} - {}", memo.id, created, &memo.content[..memo.content.len().min(60)]); "[{}] {} - {}",
} memo.id,
println!("\nTotal: {} memos", memos.len()); created,
&memo.content[..memo.content.len().min(60)]
);
} }
} println!("\nTotal: {} memos", memos.len());
Err(e) => {
println!("Error listing memos: {}", e);
} }
} }
} Err(e) => {
println!("Error listing memos: {}", e);
}
},
Command::Config(cmd) => { Command::Config(cmd) => {
if cmd.show { if cmd.show {
if config.is_configured() { if config.is_configured() {
@@ -130,163 +135,206 @@ async fn run_tui_mode() -> Result<()> {
loop { loop {
terminal.draw(|f| tui::render_app(f, &mut app))?; terminal.draw(|f| tui::render_app(f, &mut app))?;
if let Event::Key(key) = event::read()? { let event = event::read()?;
match key.kind { match event {
KeyEventKind::Press => { Event::Paste(text) => match app.mode {
if key.code == KeyCode::Char('q') && !app.loading { AppMode::Config => {
if app.mode == AppMode::MainMenu { if app.current_field == 0 {
break; app.input_base_url.push_str(&text);
} } else {
app.mode = AppMode::MainMenu; app.input_token.push_str(&text);
app.message = None;
} }
}
if key.code == KeyCode::Esc && !app.loading { AppMode::CreateMemo => {
if app.mode == AppMode::CreateMemo && app.is_editing { if app.is_editing {
app.is_editing = false; app.input_content.push_str(&text);
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;
}
}
}
} }
} }
_ => {} _ => {}
},
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()?; disable_raw_mode()?;
execute!(terminal.backend_mut(), Clear(ClearType::All))?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?; terminal.show_cursor()?;
Ok(()) 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<()> { 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 { match key.code {
KeyCode::Char('r') | KeyCode::Char('R') => { KeyCode::Char('r') | KeyCode::Char('R') => {
let client = app.get_client().await?; let client = app.get_client().await?;
match client.list_memos(None, None).await { match client.list_memos(None, None).await {
Ok(memos) => { Ok(memos) => {
app.memos = memos; app.memos = memos;
app.current_page = 0;
app.list_selected = Some(0);
app.message = Some("Memos refreshed".to_string()); app.message = Some("Memos refreshed".to_string());
} }
Err(e) => { Err(e) => {
@@ -412,33 +470,59 @@ async fn handle_list_memos_input(app: &mut App, key: &KeyEvent) -> Result<()> {
} }
} }
KeyCode::Enter => { KeyCode::Enter => {
app.message = None;
if !app.memos.is_empty() { if !app.memos.is_empty() {
if let Some(idx) = app.list_selected { if let Some(idx) = app.list_selected {
if idx < app.memos.len() { if idx < app.memos.len() {
app.selected_memo = Some(app.memos[idx].clone()); app.selected_memo = Some(app.memos[idx].clone());
app.memo_content_scroll = 0;
app.mode = AppMode::ViewMemo; app.mode = AppMode::ViewMemo;
} }
} }
} }
} }
KeyCode::Up => { KeyCode::Up => {
app.message = None;
if let Some(idx) = app.list_selected { if let Some(idx) = app.list_selected {
if idx > 0 { 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 { } else {
app.list_selected = Some(0); app.list_selected = Some(0);
} }
} }
KeyCode::Down => { KeyCode::Down => {
app.message = None;
if let Some(idx) = app.list_selected { if let Some(idx) = app.list_selected {
if idx < app.memos.len() - 1 { if idx < app.memos.len().saturating_sub(1) {
app.list_selected = Some(idx + 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() { } else if !app.memos.is_empty() {
app.list_selected = Some(0); 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(()) Ok(())
@@ -457,7 +541,8 @@ async fn handle_select_visibility_input(app: &mut App, key: &KeyEvent) -> Result
} }
KeyCode::Down => { KeyCode::Down => {
if let Some(selected) = app.visibility_list_state.selected() { 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)); app.visibility_list_state.select(Some(selected + 1));
} }
} else { } else {
@@ -465,12 +550,8 @@ async fn handle_select_visibility_input(app: &mut App, key: &KeyEvent) -> Result
} }
} }
KeyCode::Enter => { KeyCode::Enter => {
let visibility_options = vec![ let visibility_options =
"VISIBILITY_UNSPECIFIED", vec!["VISIBILITY_UNSPECIFIED", "PRIVATE", "PROTECTED", "PUBLIC"];
"PRIVATE",
"PROTECTED",
"PUBLIC",
];
if let Some(selected) = app.visibility_list_state.selected() { if let Some(selected) = app.visibility_list_state.selected() {
if selected < visibility_options.len() { if selected < visibility_options.len() {
app.input_visibility = visibility_options[selected].to_string(); app.input_visibility = visibility_options[selected].to_string();
@@ -485,3 +566,25 @@ async fn handle_select_visibility_input(app: &mut App, key: &KeyEvent) -> Result
} }
Ok(()) Ok(())
} }
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(())
}

View File

@@ -3,40 +3,100 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateMemoRequest { pub struct CreateMemoRequest {
pub content: String, pub content: String,
#[serde(rename = "visibility")] #[serde(skip_serializing_if = "Option::is_none")]
pub visibility: Option<String>, pub visibility: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memo { pub struct Memo {
#[serde(skip)]
pub id: i64, 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, pub created_ts: i64,
#[serde(rename = "updateTime", deserialize_with = "parse_timestamp", default)]
pub updated_ts: i64, pub updated_ts: i64,
#[serde(default)]
pub content: String, pub content: String,
#[serde(default)]
pub visibility: String, pub visibility: String,
#[serde(default)] #[serde(default)]
pub tags: Vec<String>, pub tags: Vec<String>,
#[serde(default)] #[serde(default)]
pub resources: Vec<Resource>, pub attachments: Vec<Attachment>,
#[serde(default)]
pub pinned: bool,
}
impl Memo {
pub fn extract_id(name: &str) -> i64 {
name.rsplit('/')
.next()
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0)
}
}
fn parse_timestamp<'de, D>(deserializer: D) -> Result<i64, D::Error>
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::<i64>()
.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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Resource { pub struct Attachment {
pub id: i64, pub name: String,
#[serde(default)]
pub filename: String, pub filename: String,
#[serde(rename = "type")] #[serde(rename = "type", default)]
pub resource_type: String, pub attachment_type: String,
pub size: i64, #[serde(default)]
pub url: String, pub size: String,
#[serde(default)]
pub external_link: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListMemosResponse { pub struct ListMemosResponse {
#[serde(deserialize_with = "deserialize_memos")]
pub memos: Vec<Memo>, pub memos: Vec<Memo>,
#[serde(default)]
pub next_page_token: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] fn deserialize_memos<'de, D>(deserializer: D) -> Result<Vec<Memo>, D::Error>
pub struct CreateMemoResponse { where
pub id: i64, D: serde::Deserializer<'de>,
{
let mut memos: Vec<Memo> = Vec::deserialize(deserializer)?;
for memo in &mut memos {
memo.id = Memo::extract_id(&memo.name);
}
Ok(memos)
} }

View File

@@ -1,15 +1,61 @@
use crate::config::Config;
use crate::client::MemosClient; use crate::client::MemosClient;
use crate::config::Config;
use crate::models::Memo; use crate::models::Memo;
use anyhow::Result; use anyhow::Result;
use chrono::Local;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; 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 ratatui::Frame;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use chrono::Local;
pub fn wrap_text(text: &str, width: usize) -> Vec<String> {
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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode { pub enum AppMode {
@@ -40,8 +86,12 @@ pub struct App {
pub visibility_list_state: ListState, pub visibility_list_state: ListState,
pub content_scroll: usize, pub content_scroll: usize,
pub help_scroll: usize, pub help_scroll: usize,
pub memo_content_scroll: usize,
pub memo_scrollbar_state: ScrollbarState,
pub scrollbar_state: ScrollbarState, pub scrollbar_state: ScrollbarState,
pub is_editing: bool, pub is_editing: bool,
pub current_page: usize,
pub items_per_page: usize,
} }
impl App { impl App {
@@ -68,8 +118,12 @@ impl App {
visibility_list_state, visibility_list_state,
content_scroll: 0usize, content_scroll: 0usize,
help_scroll: 0usize, help_scroll: 0usize,
memo_content_scroll: 0usize,
memo_scrollbar_state: ScrollbarState::default(),
scrollbar_state: ScrollbarState::default(), scrollbar_state: ScrollbarState::default(),
is_editing: false, is_editing: false,
current_page: 0,
items_per_page: 10,
} }
} }
@@ -93,7 +147,9 @@ impl App {
pub async fn get_client(&self) -> Result<MemosClient> { pub async fn get_client(&self) -> Result<MemosClient> {
let guard = self.client.lock().await; 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)); .style(Style::default().fg(Color::Yellow));
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
frame.render_widget(block, area); frame.render_widget(block, area);
let text = Paragraph::new(msg.as_str()) let text = Paragraph::new(msg.as_str()).alignment(Alignment::Center);
.alignment(Alignment::Center);
frame.render_widget(text, area); 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 current_time = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let clock = Paragraph::new(format!("🕒 {}", current_time)) let clock =
.style(Style::default().fg(Color::Yellow)); Paragraph::new(format!("🕒 {}", current_time)).style(Style::default().fg(Color::Yellow));
frame.render_widget(clock, chunks[3]); 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)); .block(Block::default().borders(Borders::ALL));
frame.render_widget(title, chunks[0]); frame.render_widget(title, chunks[0]);
let visibility_label = Paragraph::new("Visibility (PUBLIC/PRIVATE):") let visibility_label =
.style(Style::default().fg(Color::White)); Paragraph::new("Visibility (PUBLIC/PRIVATE):").style(Style::default().fg(Color::White));
frame.render_widget(visibility_label, chunks[1]); frame.render_widget(visibility_label, chunks[1]);
let visibility_input = Paragraph::new(app.input_visibility.clone()) 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")); .block(Block::default().borders(Borders::ALL).title("Visibility"));
frame.render_widget(visibility_input, chunks[1]); frame.render_widget(visibility_input, chunks[1]);
let content_label = Paragraph::new("Content:") let content_label = Paragraph::new("Content:").style(Style::default().fg(Color::White));
.style(Style::default().fg(Color::White));
frame.render_widget(content_label, chunks[2]); frame.render_widget(content_label, chunks[2]);
let content_title = if app.is_editing { let content_title = if app.is_editing {
@@ -331,47 +385,90 @@ fn render_list_memos(frame: &mut Frame, app: &mut App) {
]) ])
.split(frame.area()); .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()) .style(Style::default().fg(Color::Cyan).bold())
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Memos")); .block(Block::default().borders(Borders::ALL).title("Memos"));
frame.render_widget(title, chunks[0]); frame.render_widget(title, chunks[0]);
let memos_list: Vec<ListItem> = app.memos.iter().enumerate().map(|(idx, memo)| { if app.memos.is_empty() {
let content_preview = if memo.content.len() > 50 { let empty_msg = Paragraph::new("No memos found")
format!("{}...", &memo.content[..50]) .style(Style::default().fg(Color::Yellow))
} else { .alignment(Alignment::Center)
memo.content.clone() .block(Block::default().borders(Borders::ALL));
}; frame.render_widget(empty_msg, chunks[1]);
let created = chrono::DateTime::from_timestamp_millis(memo.created_ts) } else {
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) let memos_list: Vec<ListItem> = app.memos[start_idx..end_idx]
.unwrap_or_else(|| "Unknown".to_string()); .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::<String>())
} 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) { let style = if app.list_selected == Some(actual_idx) {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) Style::default()
} else { .fg(Color::Cyan)
Style::default() .add_modifier(Modifier::BOLD)
}; } else {
Style::default()
};
ListItem::new(Line::from(vec![ ListItem::new(Line::from(vec![
Span::raw(format!("[{}] ", memo.id)), Span::raw(format!("[{}] ", memo.id)),
Span::styled(content_preview, Style::default().fg(Color::White)), Span::styled(content_preview, Style::default().fg(Color::White)),
Span::raw(" - ").style(Style::default().fg(Color::DarkGray)), Span::raw(" - ").style(Style::default().fg(Color::DarkGray)),
Span::raw(created).style(Style::default().fg(Color::Green)), Span::raw(created).style(Style::default().fg(Color::Green)),
])).style(style) ]))
}).collect(); .style(style)
})
.collect();
let list = List::new(memos_list) let list = List::new(memos_list).block(Block::default().borders(Borders::ALL));
.block(Block::default().borders(Borders::ALL)); frame.render_widget(list, chunks[1]);
frame.render_widget(list, chunks[1]); }
let help = Paragraph::new("Enter: View | R: Refresh | Esc: Back | ↑↓: Navigate") let help = Paragraph::new("Enter: View | R: Refresh | ←→: Page | Esc: Back | ↑↓: Navigate")
.style(Style::default().fg(Color::DarkGray)) .style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center); .alignment(Alignment::Center);
frame.render_widget(help, chunks[2]); 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 { if let Some(memo) = &app.selected_memo {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
@@ -404,12 +501,49 @@ fn render_view_memo(frame: &mut Frame, app: &App) {
.alignment(Alignment::Center); .alignment(Alignment::Center);
frame.render_widget(meta_para, chunks[1]); 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<Line> = 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)) .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]); 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)) .style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center); .alignment(Alignment::Center);
frame.render_widget(help, chunks[3]); frame.render_widget(help, chunks[3]);
@@ -435,12 +569,7 @@ fn render_select_visibility(frame: &mut Frame, app: &mut App) {
frame.render_widget(block, area); frame.render_widget(block, area);
// 定义 Visibility 选项 // 定义 Visibility 选项
let visibility_options = vec![ let visibility_options = vec!["VISIBILITY_UNSPECIFIED", "PRIVATE", "PROTECTED", "PUBLIC"];
"VISIBILITY_UNSPECIFIED",
"PRIVATE",
"PROTECTED",
"PUBLIC",
];
// 创建列表项 // 创建列表项
let list_items: Vec<ListItem> = visibility_options let list_items: Vec<ListItem> = visibility_options