docs: 完善 README 并补充可见性配置说明
This commit is contained in:
141
README.md
141
README.md
@@ -2,6 +2,143 @@
|
||||
|
||||
[](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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::models::{CreateMemoRequest, CreateMemoResponse, ListMemosResponse, Memo};
|
||||
use crate::models::{CreateMemoRequest, ListMemosResponse, Memo};
|
||||
use anyhow::Result;
|
||||
use reqwest::Client;
|
||||
|
||||
@@ -22,12 +22,21 @@ impl MemosClient {
|
||||
pub async fn create_memo(&self, content: &str, visibility: Option<&str>) -> Result<Memo> {
|
||||
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<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?)
|
||||
let mut memo: Memo = response.json().await?;
|
||||
memo.id = Memo::extract_id(&memo.name);
|
||||
Ok(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 mut request = self.client
|
||||
let mut request = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.user_token));
|
||||
|
||||
|
||||
165
src/main.rs
165
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;
|
||||
@@ -69,8 +71,7 @@ async fn run_cli_command(command: Command) -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::List(cmd) => {
|
||||
match client.list_memos(Some(cmd.limit), Some(cmd.offset)).await {
|
||||
Command::List(cmd) => match client.list_memos(Some(cmd.limit), Some(cmd.offset)).await {
|
||||
Ok(memos) => {
|
||||
if memos.is_empty() {
|
||||
println!("No memos found.");
|
||||
@@ -79,7 +80,12 @@ async fn run_cli_command(command: Command) -> Result<()> {
|
||||
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!(
|
||||
"[{}] {} - {}",
|
||||
memo.id,
|
||||
created,
|
||||
&memo.content[..memo.content.len().min(60)]
|
||||
);
|
||||
}
|
||||
println!("\nTotal: {} memos", memos.len());
|
||||
}
|
||||
@@ -87,8 +93,7 @@ async fn run_cli_command(command: Command) -> Result<()> {
|
||||
Err(e) => {
|
||||
println!("Error listing memos: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Command::Config(cmd) => {
|
||||
if cmd.show {
|
||||
if config.is_configured() {
|
||||
@@ -130,7 +135,24 @@ async fn run_tui_mode() -> Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| tui::render_app(f, &mut app))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -189,7 +211,8 @@ async fn run_tui_mode() -> Result<()> {
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if let Some(selected) = app.list_state.selected() {
|
||||
if selected < 3 { // 4 items, 0-3 index
|
||||
if selected < 3 {
|
||||
// 4 items, 0-3 index
|
||||
app.list_state.select(Some(selected + 1));
|
||||
}
|
||||
} else {
|
||||
@@ -199,41 +222,59 @@ async fn run_tui_mode() -> Result<()> {
|
||||
KeyCode::Enter => {
|
||||
if let Some(selected) = app.list_state.selected() {
|
||||
match selected {
|
||||
0 => { // List Memos
|
||||
0 => {
|
||||
// List Memos
|
||||
if app.config.is_configured() {
|
||||
app.loading = true;
|
||||
terminal.draw(|f| tui::render_app(f, &mut app))?;
|
||||
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());
|
||||
app.message = Some(
|
||||
"Memos loaded successfully"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
app.message = Some(format!("Error: {}", e));
|
||||
app.message =
|
||||
Some(format!("Error: {}", e));
|
||||
}
|
||||
}
|
||||
app.loading = false;
|
||||
} else {
|
||||
app.message = Some("Please configure first".to_string());
|
||||
app.message = Some(
|
||||
"Please configure first".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
1 => { // Create Memo
|
||||
1 => {
|
||||
// Create Memo
|
||||
if app.config.is_configured() {
|
||||
app.input_content.clear();
|
||||
app.input_visibility = "PRIVATE".to_string();
|
||||
app.input_visibility =
|
||||
"PRIVATE".to_string();
|
||||
app.is_editing = false;
|
||||
app.mode = AppMode::CreateMemo;
|
||||
} else {
|
||||
app.message = Some("Please configure first".to_string());
|
||||
app.message = Some(
|
||||
"Please configure first".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
2 => { // Configuration
|
||||
app.input_base_url = app.config.base_url.clone();
|
||||
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
|
||||
3 => {
|
||||
// Quit
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
@@ -255,7 +296,9 @@ async fn run_tui_mode() -> Result<()> {
|
||||
AppMode::ListMemos => {
|
||||
handle_list_memos_input(&mut app, &key).await?;
|
||||
}
|
||||
AppMode::ViewMemo => {}
|
||||
AppMode::ViewMemo => {
|
||||
handle_view_memo_input(&mut app, &key).await?;
|
||||
}
|
||||
AppMode::SelectVisibility => {
|
||||
handle_select_visibility_input(&mut app, &key).await?;
|
||||
}
|
||||
@@ -269,7 +312,8 @@ async fn run_tui_mode() -> Result<()> {
|
||||
}
|
||||
KeyCode::Down => {
|
||||
// 帮助信息有 12 行,弹窗内容区域高度为 11 行
|
||||
if app.help_scroll < 12 - 11 { // 12 是帮助信息行数,11 是弹窗内容区域高度
|
||||
if app.help_scroll < 12 - 11 {
|
||||
// 12 是帮助信息行数,11 是弹窗内容区域高度
|
||||
app.help_scroll += 1;
|
||||
}
|
||||
}
|
||||
@@ -284,9 +328,13 @@ async fn run_tui_mode() -> Result<()> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -485,3 +566,25 @@ async fn handle_select_visibility_input(app: &mut App, key: &KeyEvent) -> Result
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[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)]
|
||||
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<Memo>,
|
||||
#[serde(default)]
|
||||
pub next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateMemoResponse {
|
||||
pub id: i64,
|
||||
fn deserialize_memos<'de, D>(deserializer: D) -> Result<Vec<Memo>, D::Error>
|
||||
where
|
||||
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)
|
||||
}
|
||||
|
||||
195
src/tui.rs
195
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<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)]
|
||||
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<MemosClient> {
|
||||
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,15 +385,54 @@ 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<ListItem> = app.memos.iter().enumerate().map(|(idx, memo)| {
|
||||
let content_preview = if memo.content.len() > 50 {
|
||||
format!("{}...", &memo.content[..50])
|
||||
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<ListItem> = 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::<String>())
|
||||
} else {
|
||||
memo.content.clone()
|
||||
};
|
||||
@@ -347,8 +440,10 @@ fn render_list_memos(frame: &mut Frame, app: &mut App) {
|
||||
.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)
|
||||
let style = if app.list_selected == Some(actual_idx) {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
@@ -358,20 +453,22 @@ fn render_list_memos(frame: &mut Frame, app: &mut App) {
|
||||
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();
|
||||
]))
|
||||
.style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(memos_list)
|
||||
.block(Block::default().borders(Borders::ALL));
|
||||
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")
|
||||
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<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))
|
||||
.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]);
|
||||
@@ -435,12 +569,7 @@ fn render_select_visibility(frame: &mut Frame, app: &mut App) {
|
||||
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<ListItem> = visibility_options
|
||||
|
||||
Reference in New Issue
Block a user