5049 lines
159 KiB
Rust
5049 lines
159 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::fs;
|
|
use std::io::Write;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
use chrono::Local;
|
|
use clap::Parser;
|
|
use log::error;
|
|
use rand::seq::SliceRandom;
|
|
use serde::{Deserialize, Serialize};
|
|
use teloxide::net::Download;
|
|
use teloxide::prelude::*;
|
|
use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, InputFile, Message, MessageId};
|
|
use tokio::sync::Mutex;
|
|
use tempfile::{NamedTempFile, TempDir, TempPath};
|
|
use uuid::Uuid;
|
|
|
|
const ACK_TTL_SECS: u64 = 5;
|
|
const UNDO_TTL_SECS: u64 = 30 * 60;
|
|
const DELETE_CONFIRM_TTL_SECS: u64 = 5 * 60;
|
|
const RESOURCE_PROMPT_TTL_SECS: u64 = 5 * 60;
|
|
const PAGE_SIZE: usize = 3;
|
|
const DOWNLOAD_PROMPT_TTL_SECS: u64 = 5 * 60;
|
|
const FINISH_TITLE_PROMPT_TTL_SECS: u64 = 5 * 60;
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct Config {
|
|
token: String,
|
|
user_id: u64,
|
|
read_later_path: PathBuf,
|
|
finished_path: PathBuf,
|
|
resources_path: PathBuf,
|
|
media_dir: PathBuf,
|
|
data_dir: PathBuf,
|
|
retry_interval_seconds: Option<u64>,
|
|
sync: Option<SyncConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
struct ConfigFile {
|
|
token: String,
|
|
user_id: UserIdInput,
|
|
read_later_path: PathBuf,
|
|
finished_path: PathBuf,
|
|
resources_path: PathBuf,
|
|
media_dir: Option<PathBuf>,
|
|
data_dir: PathBuf,
|
|
retry_interval_seconds: Option<u64>,
|
|
sync: Option<SyncConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
#[serde(untagged)]
|
|
enum UserIdInput {
|
|
Number(u64),
|
|
String(String),
|
|
File { file: PathBuf },
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
struct SyncConfig {
|
|
repo_path: PathBuf,
|
|
token_file: PathBuf,
|
|
}
|
|
|
|
#[derive(Parser, Debug)]
|
|
struct Args {
|
|
#[arg(long)]
|
|
config: PathBuf,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct EntryBlock {
|
|
lines: Vec<String>,
|
|
}
|
|
|
|
impl EntryBlock {
|
|
fn from_text(text: &str) -> Self {
|
|
let normalized = normalize_line_endings(text);
|
|
let mut lines: Vec<String> = normalized.split('\n').map(|s| s.to_string()).collect();
|
|
if lines.is_empty() {
|
|
lines.push(String::new());
|
|
}
|
|
if let Some(first) = lines.get_mut(0) {
|
|
if first.starts_with("- ") {
|
|
// Keep as-is.
|
|
} else if first.starts_with('-') {
|
|
let rest = first[1..].trim_start();
|
|
*first = format!("- {}", rest);
|
|
} else {
|
|
*first = format!("- {}", first);
|
|
}
|
|
}
|
|
EntryBlock { lines }
|
|
}
|
|
|
|
fn from_block(block: &str) -> Self {
|
|
let normalized = normalize_line_endings(block);
|
|
let lines: Vec<String> = normalized.split('\n').map(|s| s.to_string()).collect();
|
|
EntryBlock { lines }
|
|
}
|
|
|
|
fn block_string(&self) -> String {
|
|
self.lines.join("\n")
|
|
}
|
|
|
|
fn display_lines(&self) -> Vec<String> {
|
|
let mut lines = self.lines.clone();
|
|
if let Some(first) = lines.get_mut(0) {
|
|
if first.starts_with("- ") {
|
|
*first = first[2..].to_string();
|
|
} else if first.starts_with('-') {
|
|
let rest = first[1..].trim_start();
|
|
*first = rest.to_string();
|
|
}
|
|
}
|
|
lines
|
|
}
|
|
|
|
fn preview_lines(&self) -> Vec<String> {
|
|
let display = self.display_lines();
|
|
let mut preview = Vec::new();
|
|
if let Some(first) = display.get(0) {
|
|
preview.push(first.clone());
|
|
}
|
|
if let Some(second) = display.get(1) {
|
|
preview.push(second.clone());
|
|
}
|
|
if display.len() > 2 {
|
|
if let Some(last) = preview.last_mut() {
|
|
last.push_str("...");
|
|
}
|
|
}
|
|
preview
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
struct QueuedOp {
|
|
kind: QueuedOpKind,
|
|
entry: String,
|
|
#[serde(default)]
|
|
resource_path: Option<PathBuf>,
|
|
#[serde(default)]
|
|
updated_entry: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
enum QueuedOpKind {
|
|
Add,
|
|
AddResource,
|
|
Delete,
|
|
MoveToFinished,
|
|
MoveToFinishedUpdated,
|
|
MoveToReadLater,
|
|
UpdateEntry,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
struct UndoRecord {
|
|
id: String,
|
|
kind: UndoKind,
|
|
entry: String,
|
|
expires_at: u64,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
enum UndoKind {
|
|
MoveToFinished,
|
|
Delete,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct PickerState {
|
|
id: String,
|
|
chat_id: i64,
|
|
message_id: MessageId,
|
|
items: Vec<String>,
|
|
selected: Vec<bool>,
|
|
source_message_id: MessageId,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct AddPrompt {
|
|
chat_id: i64,
|
|
message_id: MessageId,
|
|
text: String,
|
|
source_message_id: MessageId,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct ResourcePickerState {
|
|
chat_id: i64,
|
|
message_id: MessageId,
|
|
text: String,
|
|
source_message_id: Option<MessageId>,
|
|
files: Vec<PathBuf>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct ResourceFilenamePrompt {
|
|
text: String,
|
|
source_message_id: Option<MessageId>,
|
|
prompt_message_id: MessageId,
|
|
expires_at: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct DownloadPickerState {
|
|
chat_id: i64,
|
|
message_id: MessageId,
|
|
links: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct DownloadLinkPrompt {
|
|
links: Vec<String>,
|
|
prompt_message_id: MessageId,
|
|
expires_at: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct FinishTitlePrompt {
|
|
session_id: String,
|
|
chat_id: i64,
|
|
entry: String,
|
|
link: String,
|
|
return_to: ListView,
|
|
prompt_message_id: MessageId,
|
|
expires_at: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct UndoSession {
|
|
chat_id: i64,
|
|
message_id: MessageId,
|
|
records: Vec<UndoRecord>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum SessionKind {
|
|
List,
|
|
Search { query: String },
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct ListSession {
|
|
id: String,
|
|
chat_id: i64,
|
|
kind: SessionKind,
|
|
entries: Vec<EntryBlock>,
|
|
view: ListView,
|
|
seen_random: HashSet<usize>,
|
|
message_id: Option<MessageId>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum ListView {
|
|
Menu,
|
|
Peek { mode: ListMode, page: usize },
|
|
Selected { return_to: Box<ListView>, index: usize },
|
|
FinishConfirm {
|
|
selected: Box<ListView>,
|
|
index: usize,
|
|
},
|
|
DeleteConfirm {
|
|
selected: Box<ListView>,
|
|
index: usize,
|
|
step: u8,
|
|
expires_at: u64,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Debug, Copy)]
|
|
enum ListMode {
|
|
Top,
|
|
Bottom,
|
|
}
|
|
|
|
struct AppState {
|
|
config: Config,
|
|
write_lock: Mutex<()>,
|
|
sessions: Mutex<HashMap<String, ListSession>>,
|
|
active_sessions: Mutex<HashMap<i64, String>>,
|
|
peeked: Mutex<HashSet<String>>,
|
|
undo_sessions: Mutex<HashMap<String, UndoSession>>,
|
|
pickers: Mutex<HashMap<String, PickerState>>,
|
|
add_prompts: Mutex<HashMap<String, AddPrompt>>,
|
|
resource_pickers: Mutex<HashMap<String, ResourcePickerState>>,
|
|
resource_filename_prompts: Mutex<HashMap<i64, ResourceFilenamePrompt>>,
|
|
download_pickers: Mutex<HashMap<String, DownloadPickerState>>,
|
|
download_link_prompts: Mutex<HashMap<i64, DownloadLinkPrompt>>,
|
|
finish_title_prompts: Mutex<HashMap<i64, FinishTitlePrompt>>,
|
|
queue: Mutex<Vec<QueuedOp>>,
|
|
undo: Mutex<Vec<UndoRecord>>,
|
|
queue_path: PathBuf,
|
|
undo_path: PathBuf,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum AddOutcome {
|
|
Added,
|
|
Duplicate,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum ModifyOutcome {
|
|
Applied,
|
|
NotFound,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
env_logger::init();
|
|
|
|
let args = Args::parse();
|
|
let config = load_config(&args.config)?;
|
|
fs::create_dir_all(&config.data_dir).context("create data_dir")?;
|
|
|
|
let queue_path = config.data_dir.join("queue.json");
|
|
let undo_path = config.data_dir.join("undo.json");
|
|
|
|
let mut undo = load_undo(&undo_path)?;
|
|
prune_undo(&mut undo);
|
|
save_undo(&undo_path, &undo)?;
|
|
|
|
let state = AppState {
|
|
config: config.clone(),
|
|
write_lock: Mutex::new(()),
|
|
sessions: Mutex::new(HashMap::new()),
|
|
active_sessions: Mutex::new(HashMap::new()),
|
|
peeked: Mutex::new(HashSet::new()),
|
|
undo_sessions: Mutex::new(HashMap::new()),
|
|
pickers: Mutex::new(HashMap::new()),
|
|
add_prompts: Mutex::new(HashMap::new()),
|
|
resource_pickers: Mutex::new(HashMap::new()),
|
|
resource_filename_prompts: Mutex::new(HashMap::new()),
|
|
download_pickers: Mutex::new(HashMap::new()),
|
|
download_link_prompts: Mutex::new(HashMap::new()),
|
|
finish_title_prompts: Mutex::new(HashMap::new()),
|
|
queue: Mutex::new(load_queue(&queue_path)?),
|
|
undo: Mutex::new(undo),
|
|
queue_path,
|
|
undo_path,
|
|
};
|
|
|
|
let state = std::sync::Arc::new(state);
|
|
|
|
let retry_secs = config.retry_interval_seconds.unwrap_or(30);
|
|
start_retry_loop(state.clone(), retry_secs);
|
|
|
|
let bot = Bot::new(config.token.clone());
|
|
|
|
let handler = dptree::entry()
|
|
.branch(Update::filter_message().endpoint(handle_message))
|
|
.branch(Update::filter_callback_query().endpoint(handle_callback));
|
|
|
|
Dispatcher::builder(bot, handler)
|
|
.dependencies(dptree::deps![state])
|
|
.enable_ctrlc_handler()
|
|
.build()
|
|
.dispatch()
|
|
.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_message(
|
|
bot: Bot,
|
|
msg: Message,
|
|
state: std::sync::Arc<AppState>,
|
|
) -> Result<()> {
|
|
let user_id = match msg.from() {
|
|
Some(user) => user.id.0,
|
|
None => return Ok(()),
|
|
};
|
|
|
|
if user_id != state.config.user_id {
|
|
return Ok(());
|
|
}
|
|
|
|
if handle_media_message(&bot, &msg, &state).await? {
|
|
return Ok(());
|
|
}
|
|
|
|
let text = match msg.text() {
|
|
Some(text) => text.to_string(),
|
|
None => return Ok(()),
|
|
};
|
|
|
|
let mut expired_finish_prompt: Option<FinishTitlePrompt> = None;
|
|
let pending_finish_prompt = {
|
|
let mut prompts = state.finish_title_prompts.lock().await;
|
|
if let Some(prompt) = prompts.remove(&msg.chat.id.0) {
|
|
if prompt.expires_at > now_ts() {
|
|
Some(prompt)
|
|
} else {
|
|
expired_finish_prompt = Some(prompt);
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
if let Some(prompt) = expired_finish_prompt {
|
|
let _ = bot
|
|
.delete_message(msg.chat.id, prompt.prompt_message_id)
|
|
.await;
|
|
}
|
|
|
|
if let Some(prompt) = pending_finish_prompt {
|
|
handle_finish_title_response(&bot, msg.chat.id, msg.id, &state, &text, prompt).await?;
|
|
return Ok(());
|
|
}
|
|
|
|
let mut expired_resource_prompt: Option<ResourceFilenamePrompt> = None;
|
|
let pending_resource_prompt = {
|
|
let mut prompts = state.resource_filename_prompts.lock().await;
|
|
if let Some(prompt) = prompts.remove(&msg.chat.id.0) {
|
|
if prompt.expires_at > now_ts() {
|
|
Some(prompt)
|
|
} else {
|
|
expired_resource_prompt = Some(prompt);
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
if let Some(prompt) = expired_resource_prompt {
|
|
let _ = bot
|
|
.delete_message(msg.chat.id, prompt.prompt_message_id)
|
|
.await;
|
|
}
|
|
|
|
if let Some(prompt) = pending_resource_prompt {
|
|
handle_resource_filename_response(&bot, msg.chat.id, msg.id, &state, &text, prompt)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
|
|
let mut expired_download_prompt: Option<DownloadLinkPrompt> = None;
|
|
let pending_download_prompt = {
|
|
let mut prompts = state.download_link_prompts.lock().await;
|
|
if let Some(prompt) = prompts.remove(&msg.chat.id.0) {
|
|
if prompt.expires_at > now_ts() {
|
|
Some(prompt)
|
|
} else {
|
|
expired_download_prompt = Some(prompt);
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
if let Some(prompt) = expired_download_prompt {
|
|
let _ = bot
|
|
.delete_message(msg.chat.id, prompt.prompt_message_id)
|
|
.await;
|
|
}
|
|
|
|
if let Some(prompt) = pending_download_prompt {
|
|
handle_download_link_response(&bot, msg.chat.id, msg.id, &state, &text, prompt).await?;
|
|
return Ok(());
|
|
}
|
|
|
|
if let Some(cmd) = parse_command(&text) {
|
|
let rest = text
|
|
.splitn(2, |c: char| c.is_whitespace())
|
|
.nth(1)
|
|
.unwrap_or("")
|
|
.trim();
|
|
match cmd {
|
|
"start" | "help" => {
|
|
let help = "Send any text to save it. Commands: /add <text>, /list, /search <query>, /download [url], /undos, /reset_peeked, /pull, /pull theirs, /push, /sync. Use --- to split a message into multiple items. In list views, use buttons for Mark Finished, Add Resource, Delete, Random. Quick actions: reply with del/delete to remove the current item, or send norm to normalize links.";
|
|
bot.send_message(msg.chat.id, help).await?;
|
|
return Ok(());
|
|
}
|
|
"add" => {
|
|
if rest.is_empty() {
|
|
send_error(&bot, msg.chat.id, "Provide text to add.").await?;
|
|
} else {
|
|
handle_add_command(bot, msg, state, rest).await?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
"list" => {
|
|
handle_list_command(bot.clone(), msg.clone(), state).await?;
|
|
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
|
return Ok(());
|
|
}
|
|
"search" | "delete" => {
|
|
if rest.is_empty() {
|
|
send_error(&bot, msg.chat.id, "Provide a search query.").await?;
|
|
} else {
|
|
handle_search_command(bot.clone(), msg.clone(), state, rest).await?;
|
|
}
|
|
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
|
return Ok(());
|
|
}
|
|
"download" => {
|
|
handle_download_command(bot.clone(), msg.clone(), state, rest).await?;
|
|
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
|
return Ok(());
|
|
}
|
|
"reset_peeked" => {
|
|
reset_peeked(&state).await;
|
|
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
|
return Ok(());
|
|
}
|
|
"undos" => {
|
|
handle_undos_command(bot.clone(), msg.clone(), state).await?;
|
|
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
|
return Ok(());
|
|
}
|
|
"pull" => {
|
|
handle_pull_command(bot.clone(), msg.clone(), state, rest).await?;
|
|
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
|
return Ok(());
|
|
}
|
|
"push" => {
|
|
handle_push_command(bot.clone(), msg.clone(), state).await?;
|
|
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
|
return Ok(());
|
|
}
|
|
"sync" => {
|
|
handle_sync_command(bot.clone(), msg.clone(), state).await?;
|
|
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
|
return Ok(());
|
|
}
|
|
_ => {
|
|
// Unknown command, fall through as text.
|
|
}
|
|
}
|
|
}
|
|
|
|
if is_instant_delete_message(&text) {
|
|
if handle_instant_delete_message(&bot, &msg, &state).await? {
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
if is_norm_message(&text) {
|
|
if handle_norm_message(&bot, &msg, &state).await? {
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
if text.contains("---") {
|
|
handle_multi_item(bot, msg.chat.id, msg.id, state, &text).await?;
|
|
} else {
|
|
handle_single_item(bot, msg.chat.id, state, &text, Some(msg.id)).await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_media_message(
|
|
bot: &Bot,
|
|
msg: &Message,
|
|
state: &std::sync::Arc<AppState>,
|
|
) -> Result<bool> {
|
|
let chat_id = msg.chat.id;
|
|
let caption = msg.caption().map(|text| text.to_string());
|
|
let media_dir = state.config.media_dir.clone();
|
|
|
|
if let Some(photos) = msg.photo() {
|
|
if let Some(photo) = pick_best_photo(photos) {
|
|
fs::create_dir_all(&media_dir)
|
|
.with_context(|| format!("create media dir {}", media_dir.display()))?;
|
|
let filename = format!("image-{}.jpg", Uuid::new_v4());
|
|
let dest_path = media_dir.join(&filename);
|
|
download_telegram_file(bot, &photo.file.id, &dest_path).await?;
|
|
let entry_text = build_media_entry_text(&filename, caption.as_deref());
|
|
handle_single_item(bot.clone(), chat_id, state.clone(), &entry_text, Some(msg.id)).await?;
|
|
return Ok(true);
|
|
}
|
|
}
|
|
|
|
if let Some(document) = msg.document() {
|
|
let mime = document.mime_type.as_ref().map(|m| m.essence_str());
|
|
let is_media = if let Some(mime) = mime {
|
|
mime.starts_with("image/") || mime.starts_with("video/")
|
|
} else {
|
|
document
|
|
.file_name
|
|
.as_deref()
|
|
.and_then(|name| Path::new(name).extension().and_then(|ext| ext.to_str()))
|
|
.map(|ext| matches!(
|
|
ext.to_ascii_lowercase().as_str(),
|
|
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "mp4" | "mov" | "mkv"
|
|
))
|
|
.unwrap_or(false)
|
|
};
|
|
if is_media {
|
|
fs::create_dir_all(&media_dir)
|
|
.with_context(|| format!("create media dir {}", media_dir.display()))?;
|
|
let ext = mime.and_then(extension_from_mime);
|
|
let filename = if let Some(name) = document.file_name.as_deref() {
|
|
sanitize_filename_with_default(name, ext)
|
|
} else {
|
|
format!("file-{}.{}", Uuid::new_v4(), ext.unwrap_or("bin"))
|
|
};
|
|
let dest_path = media_dir.join(&filename);
|
|
download_telegram_file(bot, &document.file.id, &dest_path).await?;
|
|
let entry_text = build_media_entry_text(&filename, caption.as_deref());
|
|
handle_single_item(bot.clone(), chat_id, state.clone(), &entry_text, Some(msg.id)).await?;
|
|
return Ok(true);
|
|
}
|
|
}
|
|
|
|
if let Some(video) = msg.video() {
|
|
fs::create_dir_all(&media_dir)
|
|
.with_context(|| format!("create media dir {}", media_dir.display()))?;
|
|
let ext = video
|
|
.mime_type
|
|
.as_ref()
|
|
.map(|m| m.essence_str())
|
|
.and_then(extension_from_mime);
|
|
let filename = if let Some(name) = video.file_name.as_deref() {
|
|
sanitize_filename_with_default(name, ext)
|
|
} else {
|
|
format!("video-{}.{}", Uuid::new_v4(), ext.unwrap_or("mp4"))
|
|
};
|
|
let dest_path = media_dir.join(&filename);
|
|
download_telegram_file(bot, &video.file.id, &dest_path).await?;
|
|
let entry_text = build_media_entry_text(&filename, caption.as_deref());
|
|
handle_single_item(bot.clone(), chat_id, state.clone(), &entry_text, Some(msg.id)).await?;
|
|
return Ok(true);
|
|
}
|
|
|
|
Ok(false)
|
|
}
|
|
|
|
async fn handle_norm_message(
|
|
bot: &Bot,
|
|
msg: &Message,
|
|
state: &std::sync::Arc<AppState>,
|
|
) -> Result<bool> {
|
|
let chat_id = msg.chat.id;
|
|
let session_id = {
|
|
let active = state.active_sessions.lock().await;
|
|
active.get(&chat_id.0).cloned()
|
|
};
|
|
let Some(session_id) = session_id else {
|
|
return Ok(false);
|
|
};
|
|
let mut session = {
|
|
let mut sessions = state.sessions.lock().await;
|
|
match sessions.remove(&session_id) {
|
|
Some(session) => session,
|
|
None => return Ok(false),
|
|
}
|
|
};
|
|
if session.chat_id != chat_id.0 {
|
|
state.sessions.lock().await.insert(session_id, session);
|
|
return Ok(false);
|
|
}
|
|
|
|
let peeked_snapshot = state.peeked.lock().await.clone();
|
|
let target_index = match norm_target_index(&session, &peeked_snapshot) {
|
|
Some(index) => index,
|
|
None => {
|
|
state.sessions
|
|
.lock()
|
|
.await
|
|
.insert(session.id.clone(), session);
|
|
let _ = bot.delete_message(chat_id, msg.id).await;
|
|
send_ephemeral(bot, chat_id, "Couldn't normalize.", ACK_TTL_SECS).await?;
|
|
return Ok(true);
|
|
}
|
|
};
|
|
|
|
let entry = match session.entries.get(target_index).cloned() {
|
|
Some(entry) => entry,
|
|
None => {
|
|
state.sessions
|
|
.lock()
|
|
.await
|
|
.insert(session.id.clone(), session);
|
|
let _ = bot.delete_message(chat_id, msg.id).await;
|
|
send_ephemeral(bot, chat_id, "Couldn't normalize.", ACK_TTL_SECS).await?;
|
|
return Ok(true);
|
|
}
|
|
};
|
|
|
|
let Some(normalized_entry) = normalize_entry_markdown_links(&entry) else {
|
|
state.sessions
|
|
.lock()
|
|
.await
|
|
.insert(session.id.clone(), session);
|
|
let _ = bot.delete_message(chat_id, msg.id).await;
|
|
send_ephemeral(bot, chat_id, "Couldn't normalize.", ACK_TTL_SECS).await?;
|
|
return Ok(true);
|
|
};
|
|
|
|
let op = QueuedOp {
|
|
kind: QueuedOpKind::UpdateEntry,
|
|
entry: entry.block_string(),
|
|
resource_path: None,
|
|
updated_entry: Some(normalized_entry.block_string()),
|
|
};
|
|
|
|
match apply_user_op(state, &op).await? {
|
|
UserOpOutcome::Applied(ApplyOutcome::Applied) => {
|
|
session.entries[target_index] = normalized_entry;
|
|
let (text, kb) =
|
|
render_list_view(&session.id, &session, &peeked_snapshot, &state.config);
|
|
if let Some(message_id) = session.message_id {
|
|
bot.edit_message_text(chat_id, message_id, text)
|
|
.reply_markup(kb)
|
|
.await?;
|
|
} else {
|
|
let sent = bot.send_message(chat_id, text).reply_markup(kb).await?;
|
|
session.message_id = Some(sent.id);
|
|
}
|
|
if let Err(err) =
|
|
send_embedded_media_for_view(bot, chat_id, state, &session, &peeked_snapshot).await
|
|
{
|
|
error!("send embedded media failed: {:#}", err);
|
|
}
|
|
}
|
|
UserOpOutcome::Applied(ApplyOutcome::NotFound)
|
|
| UserOpOutcome::Applied(ApplyOutcome::Duplicate) => {
|
|
send_ephemeral(bot, chat_id, "Couldn't normalize.", ACK_TTL_SECS).await?;
|
|
}
|
|
UserOpOutcome::Queued => {
|
|
send_error(bot, chat_id, "Write failed; queued for retry.").await?;
|
|
}
|
|
}
|
|
|
|
state.sessions
|
|
.lock()
|
|
.await
|
|
.insert(session.id.clone(), session);
|
|
let _ = bot.delete_message(chat_id, msg.id).await;
|
|
Ok(true)
|
|
}
|
|
|
|
async fn handle_instant_delete_message(
|
|
bot: &Bot,
|
|
msg: &Message,
|
|
state: &std::sync::Arc<AppState>,
|
|
) -> Result<bool> {
|
|
let chat_id = msg.chat.id;
|
|
let session_id = {
|
|
let active = state.active_sessions.lock().await;
|
|
active.get(&chat_id.0).cloned()
|
|
};
|
|
let Some(session_id) = session_id else {
|
|
return Ok(false);
|
|
};
|
|
let mut session = {
|
|
let mut sessions = state.sessions.lock().await;
|
|
match sessions.remove(&session_id) {
|
|
Some(session) => session,
|
|
None => return Ok(false),
|
|
}
|
|
};
|
|
if session.chat_id != chat_id.0 {
|
|
state.sessions.lock().await.insert(session_id, session);
|
|
return Ok(false);
|
|
}
|
|
|
|
let peeked_snapshot = state.peeked.lock().await.clone();
|
|
let target_index = match norm_target_index(&session, &peeked_snapshot) {
|
|
Some(index) => index,
|
|
None => {
|
|
state.sessions
|
|
.lock()
|
|
.await
|
|
.insert(session.id.clone(), session);
|
|
let _ = bot.delete_message(chat_id, msg.id).await;
|
|
send_ephemeral(bot, chat_id, "Couldn't delete.", ACK_TTL_SECS).await?;
|
|
return Ok(true);
|
|
}
|
|
};
|
|
|
|
let entry_block = match session.entries.get(target_index).map(|e| e.block_string()) {
|
|
Some(entry) => entry,
|
|
None => {
|
|
state.sessions
|
|
.lock()
|
|
.await
|
|
.insert(session.id.clone(), session);
|
|
let _ = bot.delete_message(chat_id, msg.id).await;
|
|
send_ephemeral(bot, chat_id, "Couldn't delete.", ACK_TTL_SECS).await?;
|
|
return Ok(true);
|
|
}
|
|
};
|
|
|
|
let op = QueuedOp {
|
|
kind: QueuedOpKind::Delete,
|
|
entry: entry_block,
|
|
resource_path: None,
|
|
updated_entry: None,
|
|
};
|
|
|
|
match apply_user_op(state, &op).await? {
|
|
UserOpOutcome::Applied(ApplyOutcome::Applied) => {
|
|
session.entries.remove(target_index);
|
|
if let ListView::Selected { return_to, .. } = session.view.clone() {
|
|
session.view = *return_to;
|
|
}
|
|
let _ = add_undo(state, UndoKind::Delete, op.entry.clone()).await?;
|
|
normalize_peek_view(&mut session, &peeked_snapshot);
|
|
let (text, kb) =
|
|
render_list_view(&session.id, &session, &peeked_snapshot, &state.config);
|
|
if let Some(message_id) = session.message_id {
|
|
bot.edit_message_text(chat_id, message_id, text)
|
|
.reply_markup(kb)
|
|
.await?;
|
|
} else {
|
|
let sent = bot.send_message(chat_id, text).reply_markup(kb).await?;
|
|
session.message_id = Some(sent.id);
|
|
}
|
|
if let Err(err) =
|
|
send_embedded_media_for_view(bot, chat_id, state, &session, &peeked_snapshot).await
|
|
{
|
|
error!("send embedded media failed: {:#}", err);
|
|
}
|
|
}
|
|
UserOpOutcome::Applied(ApplyOutcome::NotFound)
|
|
| UserOpOutcome::Applied(ApplyOutcome::Duplicate) => {
|
|
send_ephemeral(bot, chat_id, "Couldn't delete.", ACK_TTL_SECS).await?;
|
|
}
|
|
UserOpOutcome::Queued => {
|
|
send_error(bot, chat_id, "Write failed; queued for retry.").await?;
|
|
}
|
|
}
|
|
|
|
state.sessions
|
|
.lock()
|
|
.await
|
|
.insert(session.id.clone(), session);
|
|
let _ = bot.delete_message(chat_id, msg.id).await;
|
|
Ok(true)
|
|
}
|
|
|
|
fn is_instant_delete_message(text: &str) -> bool {
|
|
matches!(text.trim().to_lowercase().as_str(), "del" | "delete")
|
|
}
|
|
|
|
fn is_norm_message(text: &str) -> bool {
|
|
text.trim().eq_ignore_ascii_case("norm")
|
|
}
|
|
|
|
async fn handle_callback(
|
|
bot: Bot,
|
|
q: CallbackQuery,
|
|
state: std::sync::Arc<AppState>,
|
|
) -> Result<()> {
|
|
let user_id = q.from.id.0;
|
|
if user_id != state.config.user_id {
|
|
return Ok(());
|
|
}
|
|
|
|
if let Some(data) = q.data.as_deref() {
|
|
if data.starts_with("ls:") {
|
|
handle_list_callback(bot, q, state).await?;
|
|
} else if data.starts_with("pick:") {
|
|
handle_picker_callback(bot, q, state).await?;
|
|
} else if data.starts_with("add:") {
|
|
handle_add_callback(bot, q, state).await?;
|
|
} else if data.starts_with("res:") {
|
|
handle_resource_callback(bot, q, state).await?;
|
|
} else if data.starts_with("dl:") {
|
|
handle_download_callback(bot, q, state).await?;
|
|
} else if data.starts_with("msgdel") {
|
|
handle_message_delete_callback(bot, q).await?;
|
|
} else if data.starts_with("undos:") {
|
|
handle_undos_callback(bot, q, state).await?;
|
|
} else if data.starts_with("undo:") {
|
|
handle_undo_callback(bot, q, state).await?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_list_command(
|
|
bot: Bot,
|
|
msg: Message,
|
|
state: std::sync::Arc<AppState>,
|
|
) -> Result<()> {
|
|
let entries = read_entries(&state.config.read_later_path)?.1;
|
|
let session_id = short_id();
|
|
let mut session = ListSession {
|
|
id: session_id.clone(),
|
|
chat_id: msg.chat.id.0,
|
|
kind: SessionKind::List,
|
|
entries,
|
|
view: ListView::Menu,
|
|
seen_random: HashSet::new(),
|
|
message_id: None,
|
|
};
|
|
|
|
let (text, kb) = build_menu_view(&session_id, &session);
|
|
let sent = bot
|
|
.send_message(msg.chat.id, text)
|
|
.reply_markup(kb)
|
|
.await?;
|
|
session.message_id = Some(sent.id);
|
|
state
|
|
.sessions
|
|
.lock()
|
|
.await
|
|
.insert(session_id.clone(), session);
|
|
state
|
|
.active_sessions
|
|
.lock()
|
|
.await
|
|
.insert(msg.chat.id.0, session_id);
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_search_command(
|
|
bot: Bot,
|
|
msg: Message,
|
|
state: std::sync::Arc<AppState>,
|
|
query: &str,
|
|
) -> Result<()> {
|
|
let entries = read_entries(&state.config.read_later_path)?.1;
|
|
let matches = search_entries(&entries, query);
|
|
|
|
if matches.is_empty() {
|
|
send_ephemeral(&bot, msg.chat.id, "No matches.", ACK_TTL_SECS).await?;
|
|
return Ok(());
|
|
}
|
|
|
|
let session_id = short_id();
|
|
let mut session = ListSession {
|
|
id: session_id.clone(),
|
|
chat_id: msg.chat.id.0,
|
|
kind: SessionKind::Search {
|
|
query: query.to_string(),
|
|
},
|
|
entries: matches,
|
|
view: ListView::Peek {
|
|
mode: ListMode::Top,
|
|
page: 0,
|
|
},
|
|
seen_random: HashSet::new(),
|
|
message_id: None,
|
|
};
|
|
|
|
let peeked_snapshot = state.peeked.lock().await.clone();
|
|
let (text, kb) = render_list_view(&session_id, &session, &peeked_snapshot, &state.config);
|
|
let sent = bot
|
|
.send_message(msg.chat.id, text)
|
|
.reply_markup(kb)
|
|
.await?;
|
|
session.message_id = Some(sent.id);
|
|
state
|
|
.sessions
|
|
.lock()
|
|
.await
|
|
.insert(session_id.clone(), session);
|
|
state
|
|
.active_sessions
|
|
.lock()
|
|
.await
|
|
.insert(msg.chat.id.0, session_id);
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_download_command(
|
|
bot: Bot,
|
|
msg: Message,
|
|
state: std::sync::Arc<AppState>,
|
|
rest: &str,
|
|
) -> Result<()> {
|
|
let links = if !rest.trim().is_empty() {
|
|
extract_links(rest)
|
|
} else {
|
|
match active_entry_text(&state, msg.chat.id.0).await {
|
|
Some(text) => extract_links(&text),
|
|
None => Vec::new(),
|
|
}
|
|
};
|
|
|
|
start_download_picker(&bot, msg.chat.id, &state, links).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn active_entry_text(state: &std::sync::Arc<AppState>, chat_id: i64) -> Option<String> {
|
|
let session_id = {
|
|
let active = state.active_sessions.lock().await;
|
|
active.get(&chat_id).cloned()
|
|
}?;
|
|
let session = {
|
|
let sessions = state.sessions.lock().await;
|
|
sessions.get(&session_id).cloned()
|
|
}?;
|
|
if session.chat_id != chat_id {
|
|
return None;
|
|
}
|
|
let peeked_snapshot = state.peeked.lock().await.clone();
|
|
match &session.view {
|
|
ListView::Selected { index, .. } => session
|
|
.entries
|
|
.get(*index)
|
|
.map(|entry| entry.display_lines().join("\n")),
|
|
ListView::Peek { mode, page } => {
|
|
let indices = peek_indices_for_session(&session, &peeked_snapshot, *mode, *page);
|
|
if indices.len() == 1 {
|
|
session
|
|
.entries
|
|
.get(indices[0])
|
|
.map(|entry| entry.display_lines().join("\n"))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
async fn handle_push_command(
|
|
bot: Bot,
|
|
msg: Message,
|
|
state: std::sync::Arc<AppState>,
|
|
) -> Result<()> {
|
|
let Some(sync) = state.config.sync.clone() else {
|
|
send_error(
|
|
&bot,
|
|
msg.chat.id,
|
|
"Sync not configured. Set settings.sync.repo_path and settings.sync.token_file.",
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
};
|
|
|
|
let chat_id = msg.chat.id;
|
|
let outcome = tokio::task::spawn_blocking(move || run_push(&sync))
|
|
.await
|
|
.context("push task failed")?;
|
|
|
|
match outcome {
|
|
Ok(PushOutcome::NoChanges) => {
|
|
send_ephemeral(&bot, chat_id, "Nothing to sync.", ACK_TTL_SECS).await?;
|
|
}
|
|
Ok(PushOutcome::Pushed) => {
|
|
send_ephemeral(&bot, chat_id, "Synced.", ACK_TTL_SECS).await?;
|
|
}
|
|
Err(err) => {
|
|
send_error(&bot, chat_id, &err.to_string()).await?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_pull_command(
|
|
bot: Bot,
|
|
msg: Message,
|
|
state: std::sync::Arc<AppState>,
|
|
rest: &str,
|
|
) -> Result<()> {
|
|
let Some(sync) = state.config.sync.clone() else {
|
|
send_error(
|
|
&bot,
|
|
msg.chat.id,
|
|
"Sync not configured. Set settings.sync.repo_path and settings.sync.token_file.",
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
};
|
|
|
|
let mode = match parse_pull_mode(rest) {
|
|
Ok(mode) => mode,
|
|
Err(message) => {
|
|
send_error(&bot, msg.chat.id, &message).await?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
let chat_id = msg.chat.id;
|
|
let outcome = tokio::task::spawn_blocking(move || run_pull(&sync, mode))
|
|
.await
|
|
.context("pull task failed")?;
|
|
|
|
match outcome {
|
|
Ok(PullOutcome::UpToDate) => {
|
|
send_ephemeral(&bot, chat_id, "Already up to date.", ACK_TTL_SECS).await?;
|
|
}
|
|
Ok(PullOutcome::Pulled) => {
|
|
send_ephemeral(&bot, chat_id, "Pulled.", ACK_TTL_SECS).await?;
|
|
}
|
|
Err(err) => {
|
|
send_error(&bot, chat_id, &err.to_string()).await?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_sync_command(
|
|
bot: Bot,
|
|
msg: Message,
|
|
state: std::sync::Arc<AppState>,
|
|
) -> Result<()> {
|
|
let Some(sync) = state.config.sync.clone() else {
|
|
send_error(
|
|
&bot,
|
|
msg.chat.id,
|
|
"Sync not configured. Set settings.sync.repo_path and settings.sync.token_file.",
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
};
|
|
|
|
let chat_id = msg.chat.id;
|
|
let outcome = tokio::task::spawn_blocking(move || run_sync(&sync))
|
|
.await
|
|
.context("sync task failed")?;
|
|
|
|
match outcome {
|
|
Ok(SyncOutcome::Synced) => {
|
|
send_ephemeral(&bot, chat_id, "Synced.", ACK_TTL_SECS).await?;
|
|
}
|
|
Ok(SyncOutcome::NoChanges) => {
|
|
send_ephemeral(&bot, chat_id, "Nothing to sync.", ACK_TTL_SECS).await?;
|
|
}
|
|
Err(err) => {
|
|
send_error(&bot, chat_id, &err.to_string()).await?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_undos_command(
|
|
bot: Bot,
|
|
msg: Message,
|
|
state: std::sync::Arc<AppState>,
|
|
) -> Result<()> {
|
|
let (records, undo_snapshot) = {
|
|
let mut undo = state.undo.lock().await;
|
|
prune_undo(&mut undo);
|
|
let snapshot = undo.clone();
|
|
(undo.clone(), snapshot)
|
|
};
|
|
save_undo(&state.undo_path, &undo_snapshot)?;
|
|
|
|
if records.is_empty() {
|
|
send_ephemeral(&bot, msg.chat.id, "No undos.", ACK_TTL_SECS).await?;
|
|
return Ok(());
|
|
}
|
|
|
|
let session_id = short_id();
|
|
let (text, kb) = build_undos_view(&session_id, &records);
|
|
let sent = bot.send_message(msg.chat.id, text).reply_markup(kb).await?;
|
|
let session = UndoSession {
|
|
chat_id: msg.chat.id.0,
|
|
message_id: sent.id,
|
|
records,
|
|
};
|
|
state
|
|
.undo_sessions
|
|
.lock()
|
|
.await
|
|
.insert(session_id, session);
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_single_item(
|
|
bot: Bot,
|
|
chat_id: ChatId,
|
|
state: std::sync::Arc<AppState>,
|
|
text: &str,
|
|
source_message_id: Option<MessageId>,
|
|
) -> Result<()> {
|
|
let entry = EntryBlock::from_text(text);
|
|
let op = QueuedOp {
|
|
kind: QueuedOpKind::Add,
|
|
entry: entry.block_string(),
|
|
resource_path: None,
|
|
updated_entry: None,
|
|
};
|
|
|
|
match apply_user_op(&state, &op).await? {
|
|
UserOpOutcome::Applied(ApplyOutcome::Applied) => {
|
|
send_ephemeral(&bot, chat_id, "Saved.", ACK_TTL_SECS).await?;
|
|
if let Some(message_id) = source_message_id {
|
|
let _ = bot.delete_message(chat_id, message_id).await;
|
|
}
|
|
}
|
|
UserOpOutcome::Applied(ApplyOutcome::Duplicate) => {
|
|
send_ephemeral(&bot, chat_id, "Already saved.", ACK_TTL_SECS).await?;
|
|
if let Some(message_id) = source_message_id {
|
|
let _ = bot.delete_message(chat_id, message_id).await;
|
|
}
|
|
}
|
|
UserOpOutcome::Applied(ApplyOutcome::NotFound) => {
|
|
// Not used for add.
|
|
}
|
|
UserOpOutcome::Queued => {
|
|
send_error(&bot, chat_id, "Write failed; queued for retry.").await?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_multi_item(
|
|
bot: Bot,
|
|
chat_id: ChatId,
|
|
source_message_id: MessageId,
|
|
state: std::sync::Arc<AppState>,
|
|
text: &str,
|
|
) -> Result<()> {
|
|
let items = split_items(text);
|
|
if items.is_empty() {
|
|
send_error(&bot, chat_id, "No items found.").await?;
|
|
return Ok(());
|
|
}
|
|
|
|
let picker_id = short_id();
|
|
let selected = vec![false; items.len()];
|
|
let view_text = build_picker_text(&items, &selected);
|
|
let kb = build_picker_keyboard(&picker_id, &selected);
|
|
let sent = bot.send_message(chat_id, view_text).reply_markup(kb).await?;
|
|
|
|
let picker = PickerState {
|
|
id: picker_id.clone(),
|
|
chat_id: chat_id.0,
|
|
message_id: sent.id,
|
|
items,
|
|
selected,
|
|
source_message_id,
|
|
};
|
|
state.pickers.lock().await.insert(picker_id, picker);
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_add_command(
|
|
bot: Bot,
|
|
msg: Message,
|
|
state: std::sync::Arc<AppState>,
|
|
text: &str,
|
|
) -> Result<()> {
|
|
let prompt_id = short_id();
|
|
let kb = build_add_prompt_keyboard(&prompt_id);
|
|
let prompt_text = "Add to reading list or resources?";
|
|
let sent = bot.send_message(msg.chat.id, prompt_text).reply_markup(kb).await?;
|
|
|
|
let prompt = AddPrompt {
|
|
chat_id: msg.chat.id.0,
|
|
message_id: sent.id,
|
|
text: text.to_string(),
|
|
source_message_id: msg.id,
|
|
};
|
|
state.add_prompts.lock().await.insert(prompt_id, prompt);
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_add_callback(
|
|
bot: Bot,
|
|
q: CallbackQuery,
|
|
state: std::sync::Arc<AppState>,
|
|
) -> Result<()> {
|
|
let Some(message) = q.message.clone() else {
|
|
return Ok(());
|
|
};
|
|
let Some(data) = q.data.as_deref() else {
|
|
return Ok(());
|
|
};
|
|
let mut parts = data.split(':');
|
|
let _ = parts.next();
|
|
let prompt_id = match parts.next() {
|
|
Some(id) => id.to_string(),
|
|
None => return Ok(()),
|
|
};
|
|
let action = match parts.next() {
|
|
Some(action) => action,
|
|
None => return Ok(()),
|
|
};
|
|
|
|
let prompt = {
|
|
let mut prompts = state.add_prompts.lock().await;
|
|
let prompt = match prompts.remove(&prompt_id) {
|
|
Some(prompt) => prompt,
|
|
None => {
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
if prompt.chat_id != message.chat.id.0 || prompt.message_id != message.id {
|
|
prompts.insert(prompt_id.clone(), prompt);
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
prompt
|
|
};
|
|
|
|
match action {
|
|
"normal" => {
|
|
handle_single_item(
|
|
bot.clone(),
|
|
message.chat.id,
|
|
state.clone(),
|
|
&prompt.text,
|
|
Some(prompt.source_message_id),
|
|
)
|
|
.await?;
|
|
}
|
|
"resource" => {
|
|
start_resource_picker(
|
|
&bot,
|
|
message.chat.id,
|
|
&state,
|
|
&prompt.text,
|
|
Some(prompt.source_message_id),
|
|
)
|
|
.await?;
|
|
}
|
|
"cancel" => {}
|
|
_ => {
|
|
let mut prompts = state.add_prompts.lock().await;
|
|
prompts.insert(prompt_id, prompt);
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
let _ = bot.delete_message(message.chat.id, message.id).await;
|
|
bot.answer_callback_query(q.id).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn start_resource_picker(
|
|
bot: &Bot,
|
|
chat_id: ChatId,
|
|
state: &std::sync::Arc<AppState>,
|
|
text: &str,
|
|
source_message_id: Option<MessageId>,
|
|
) -> Result<()> {
|
|
let files = list_resource_files(&state.config.resources_path)?;
|
|
let picker_id = short_id();
|
|
let kb = build_resource_picker_keyboard(&picker_id, &files);
|
|
let prompt_text = if files.is_empty() {
|
|
"No resource files found. Create a new one?"
|
|
} else {
|
|
"Choose a resource file:"
|
|
};
|
|
let sent = bot.send_message(chat_id, prompt_text).reply_markup(kb).await?;
|
|
|
|
let picker = ResourcePickerState {
|
|
chat_id: chat_id.0,
|
|
message_id: sent.id,
|
|
text: text.to_string(),
|
|
source_message_id,
|
|
files,
|
|
};
|
|
state
|
|
.resource_pickers
|
|
.lock()
|
|
.await
|
|
.insert(picker_id, picker);
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_resource_callback(
|
|
bot: Bot,
|
|
q: CallbackQuery,
|
|
state: std::sync::Arc<AppState>,
|
|
) -> Result<()> {
|
|
let Some(message) = q.message.clone() else {
|
|
return Ok(());
|
|
};
|
|
let Some(data) = q.data.as_deref() else {
|
|
return Ok(());
|
|
};
|
|
let mut parts = data.split(':');
|
|
let _ = parts.next();
|
|
let picker_id = match parts.next() {
|
|
Some(id) => id.to_string(),
|
|
None => return Ok(()),
|
|
};
|
|
let action = match parts.next() {
|
|
Some(action) => action,
|
|
None => return Ok(()),
|
|
};
|
|
|
|
let picker = {
|
|
let mut pickers = state.resource_pickers.lock().await;
|
|
let picker = match pickers.remove(&picker_id) {
|
|
Some(picker) => picker,
|
|
None => {
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
if picker.chat_id != message.chat.id.0 || picker.message_id != message.id {
|
|
pickers.insert(picker_id.clone(), picker);
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
picker
|
|
};
|
|
|
|
let mut reinsert = false;
|
|
match action {
|
|
"file" => {
|
|
let index = parts.next().and_then(|p| p.parse::<usize>().ok());
|
|
if let Some(index) = index {
|
|
if let Some(path) = picker.files.get(index).cloned() {
|
|
add_resource_from_text(
|
|
&bot,
|
|
message.chat.id,
|
|
&state,
|
|
path,
|
|
&picker.text,
|
|
picker.source_message_id.clone(),
|
|
)
|
|
.await?;
|
|
let _ = bot.delete_message(message.chat.id, message.id).await;
|
|
} else {
|
|
reinsert = true;
|
|
}
|
|
} else {
|
|
reinsert = true;
|
|
}
|
|
}
|
|
"new" => {
|
|
let prompt_text = "Send the new resource filename (example: Resources.md).";
|
|
let sent = bot.send_message(message.chat.id, prompt_text).await?;
|
|
let prompt = ResourceFilenamePrompt {
|
|
text: picker.text.clone(),
|
|
source_message_id: picker.source_message_id.clone(),
|
|
prompt_message_id: sent.id,
|
|
expires_at: now_ts() + RESOURCE_PROMPT_TTL_SECS,
|
|
};
|
|
let previous = state
|
|
.resource_filename_prompts
|
|
.lock()
|
|
.await
|
|
.insert(message.chat.id.0, prompt);
|
|
if let Some(previous) = previous {
|
|
let _ = bot
|
|
.delete_message(message.chat.id, previous.prompt_message_id)
|
|
.await;
|
|
}
|
|
let _ = bot.delete_message(message.chat.id, message.id).await;
|
|
}
|
|
"cancel" => {
|
|
let _ = bot.delete_message(message.chat.id, message.id).await;
|
|
}
|
|
_ => {
|
|
reinsert = true;
|
|
}
|
|
}
|
|
|
|
if reinsert {
|
|
state
|
|
.resource_pickers
|
|
.lock()
|
|
.await
|
|
.insert(picker_id, picker);
|
|
}
|
|
|
|
bot.answer_callback_query(q.id).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn add_resource_from_text(
|
|
bot: &Bot,
|
|
chat_id: ChatId,
|
|
state: &std::sync::Arc<AppState>,
|
|
resource_path: PathBuf,
|
|
text: &str,
|
|
source_message_id: Option<MessageId>,
|
|
) -> Result<()> {
|
|
let entry_block = resource_block_from_text(text);
|
|
let op = QueuedOp {
|
|
kind: QueuedOpKind::AddResource,
|
|
entry: entry_block,
|
|
resource_path: Some(resource_path),
|
|
updated_entry: None,
|
|
};
|
|
|
|
match apply_user_op(state, &op).await? {
|
|
UserOpOutcome::Applied(ApplyOutcome::Applied) => {
|
|
send_ephemeral(bot, chat_id, "Added to resources.", ACK_TTL_SECS).await?;
|
|
if let Some(message_id) = source_message_id {
|
|
let _ = bot.delete_message(chat_id, message_id).await;
|
|
}
|
|
}
|
|
UserOpOutcome::Applied(ApplyOutcome::Duplicate) => {
|
|
send_ephemeral(bot, chat_id, "Already in resources.", ACK_TTL_SECS).await?;
|
|
if let Some(message_id) = source_message_id {
|
|
let _ = bot.delete_message(chat_id, message_id).await;
|
|
}
|
|
}
|
|
UserOpOutcome::Applied(ApplyOutcome::NotFound) => {}
|
|
UserOpOutcome::Queued => {
|
|
send_error(bot, chat_id, "Write failed; queued for retry.").await?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_resource_filename_response(
|
|
bot: &Bot,
|
|
chat_id: ChatId,
|
|
message_id: MessageId,
|
|
state: &std::sync::Arc<AppState>,
|
|
text: &str,
|
|
prompt: ResourceFilenamePrompt,
|
|
) -> Result<()> {
|
|
let filename = match sanitize_resource_filename(text) {
|
|
Ok(name) => name,
|
|
Err(err) => {
|
|
send_error(bot, chat_id, &err.to_string()).await?;
|
|
let mut prompts = state.resource_filename_prompts.lock().await;
|
|
prompts.insert(
|
|
chat_id.0,
|
|
ResourceFilenamePrompt {
|
|
expires_at: now_ts() + RESOURCE_PROMPT_TTL_SECS,
|
|
..prompt
|
|
},
|
|
);
|
|
let _ = bot.delete_message(chat_id, message_id).await;
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
let resource_path = state.config.resources_path.join(filename);
|
|
add_resource_from_text(
|
|
bot,
|
|
chat_id,
|
|
state,
|
|
resource_path,
|
|
&prompt.text,
|
|
prompt.source_message_id.clone(),
|
|
)
|
|
.await?;
|
|
|
|
let _ = bot
|
|
.delete_message(chat_id, prompt.prompt_message_id)
|
|
.await;
|
|
let _ = bot.delete_message(chat_id, message_id).await;
|
|
Ok(())
|
|
}
|
|
|
|
async fn start_download_picker(
|
|
bot: &Bot,
|
|
chat_id: ChatId,
|
|
state: &std::sync::Arc<AppState>,
|
|
links: Vec<String>,
|
|
) -> Result<()> {
|
|
let picker_id = short_id();
|
|
let text = build_download_picker_text(&links);
|
|
let kb = build_download_picker_keyboard(&picker_id, &links);
|
|
let sent = bot.send_message(chat_id, text).reply_markup(kb).await?;
|
|
let picker = DownloadPickerState {
|
|
chat_id: chat_id.0,
|
|
message_id: sent.id,
|
|
links,
|
|
};
|
|
state
|
|
.download_pickers
|
|
.lock()
|
|
.await
|
|
.insert(picker_id, picker);
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_download_callback(
|
|
bot: Bot,
|
|
q: CallbackQuery,
|
|
state: std::sync::Arc<AppState>,
|
|
) -> Result<()> {
|
|
let Some(message) = q.message.clone() else {
|
|
return Ok(());
|
|
};
|
|
let Some(data) = q.data.as_deref() else {
|
|
return Ok(());
|
|
};
|
|
let mut parts = data.split(':');
|
|
let _ = parts.next();
|
|
let picker_id = match parts.next() {
|
|
Some(id) => id.to_string(),
|
|
None => return Ok(()),
|
|
};
|
|
let action = match parts.next() {
|
|
Some(action) => action,
|
|
None => return Ok(()),
|
|
};
|
|
|
|
let picker = {
|
|
let mut pickers = state.download_pickers.lock().await;
|
|
let picker = match pickers.remove(&picker_id) {
|
|
Some(picker) => picker,
|
|
None => {
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
if picker.chat_id != message.chat.id.0 || picker.message_id != message.id {
|
|
pickers.insert(picker_id.clone(), picker);
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
picker
|
|
};
|
|
|
|
let mut reinsert = false;
|
|
bot.answer_callback_query(q.id).await?;
|
|
|
|
match action {
|
|
"send" => {
|
|
let index = parts.next().and_then(|p| p.parse::<usize>().ok());
|
|
if let Some(index) = index {
|
|
if let Some(link) = picker.links.get(index).cloned() {
|
|
match download_and_send_link(&bot, message.chat.id, &link).await {
|
|
Ok(()) => {
|
|
let _ = bot.delete_message(message.chat.id, message.id).await;
|
|
}
|
|
Err(err) => {
|
|
send_error(&bot, message.chat.id, &err.to_string()).await?;
|
|
reinsert = true;
|
|
}
|
|
}
|
|
} else {
|
|
reinsert = true;
|
|
}
|
|
} else {
|
|
reinsert = true;
|
|
}
|
|
}
|
|
"save" => {
|
|
let index = parts.next().and_then(|p| p.parse::<usize>().ok());
|
|
if let Some(index) = index {
|
|
if let Some(link) = picker.links.get(index).cloned() {
|
|
match download_and_save_link(&state, &link).await {
|
|
Ok(path) => {
|
|
let note = format!("Saved to {}", path.display());
|
|
let kb = InlineKeyboardMarkup::new(vec![vec![
|
|
InlineKeyboardButton::callback("Delete message", "msgdel"),
|
|
]]);
|
|
bot.send_message(message.chat.id, note)
|
|
.reply_markup(kb)
|
|
.await?;
|
|
let _ = bot.delete_message(message.chat.id, message.id).await;
|
|
}
|
|
Err(err) => {
|
|
send_error(&bot, message.chat.id, &err.to_string()).await?;
|
|
reinsert = true;
|
|
}
|
|
}
|
|
} else {
|
|
reinsert = true;
|
|
}
|
|
} else {
|
|
reinsert = true;
|
|
}
|
|
}
|
|
"add" => {
|
|
let prompt_text = "Send a link to add.";
|
|
let sent = bot.send_message(message.chat.id, prompt_text).await?;
|
|
let prompt = DownloadLinkPrompt {
|
|
links: picker.links.clone(),
|
|
prompt_message_id: sent.id,
|
|
expires_at: now_ts() + DOWNLOAD_PROMPT_TTL_SECS,
|
|
};
|
|
let previous = state
|
|
.download_link_prompts
|
|
.lock()
|
|
.await
|
|
.insert(message.chat.id.0, prompt);
|
|
if let Some(previous) = previous {
|
|
let _ = bot
|
|
.delete_message(message.chat.id, previous.prompt_message_id)
|
|
.await;
|
|
}
|
|
let _ = bot.delete_message(message.chat.id, message.id).await;
|
|
}
|
|
"cancel" => {
|
|
let _ = bot.delete_message(message.chat.id, message.id).await;
|
|
}
|
|
_ => {
|
|
reinsert = true;
|
|
}
|
|
}
|
|
|
|
if reinsert {
|
|
state
|
|
.download_pickers
|
|
.lock()
|
|
.await
|
|
.insert(picker_id, picker);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_download_link_response(
|
|
bot: &Bot,
|
|
chat_id: ChatId,
|
|
message_id: MessageId,
|
|
state: &std::sync::Arc<AppState>,
|
|
text: &str,
|
|
prompt: DownloadLinkPrompt,
|
|
) -> Result<()> {
|
|
let new_links = extract_links(text);
|
|
if new_links.is_empty() {
|
|
send_error(bot, chat_id, "No links found. Send a URL.").await?;
|
|
let mut prompts = state.download_link_prompts.lock().await;
|
|
prompts.insert(
|
|
chat_id.0,
|
|
DownloadLinkPrompt {
|
|
expires_at: now_ts() + DOWNLOAD_PROMPT_TTL_SECS,
|
|
..prompt
|
|
},
|
|
);
|
|
let _ = bot.delete_message(chat_id, message_id).await;
|
|
return Ok(());
|
|
}
|
|
|
|
let mut links = prompt.links.clone();
|
|
for link in new_links {
|
|
if !links.contains(&link) {
|
|
links.push(link);
|
|
}
|
|
}
|
|
start_download_picker(bot, chat_id, state, links).await?;
|
|
let _ = bot
|
|
.delete_message(chat_id, prompt.prompt_message_id)
|
|
.await;
|
|
let _ = bot.delete_message(chat_id, message_id).await;
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_message_delete_callback(bot: Bot, q: CallbackQuery) -> Result<()> {
|
|
if let Some(message) = q.message.clone() {
|
|
let _ = bot.delete_message(message.chat.id, message.id).await;
|
|
}
|
|
bot.answer_callback_query(q.id).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_finish_title_response(
|
|
bot: &Bot,
|
|
chat_id: ChatId,
|
|
message_id: MessageId,
|
|
state: &std::sync::Arc<AppState>,
|
|
text: &str,
|
|
prompt: FinishTitlePrompt,
|
|
) -> Result<()> {
|
|
let title = text.lines().next().unwrap_or("").trim();
|
|
if title.is_empty() {
|
|
send_error(bot, chat_id, "Provide a title.").await?;
|
|
let mut prompts = state.finish_title_prompts.lock().await;
|
|
prompts.insert(
|
|
chat_id.0,
|
|
FinishTitlePrompt {
|
|
expires_at: now_ts() + FINISH_TITLE_PROMPT_TTL_SECS,
|
|
..prompt
|
|
},
|
|
);
|
|
let _ = bot.delete_message(chat_id, message_id).await;
|
|
return Ok(());
|
|
}
|
|
|
|
let updated_entry = entry_with_title(&prompt.entry, title, &prompt.link);
|
|
let mut session = {
|
|
let mut sessions = state.sessions.lock().await;
|
|
let session = match sessions.remove(&prompt.session_id) {
|
|
Some(session) => session,
|
|
None => {
|
|
let _ = bot
|
|
.delete_message(chat_id, prompt.prompt_message_id)
|
|
.await;
|
|
let _ = bot.delete_message(chat_id, message_id).await;
|
|
return Ok(());
|
|
}
|
|
};
|
|
if session.chat_id != prompt.chat_id {
|
|
sessions.insert(prompt.session_id.clone(), session);
|
|
let _ = bot
|
|
.delete_message(chat_id, prompt.prompt_message_id)
|
|
.await;
|
|
let _ = bot.delete_message(chat_id, message_id).await;
|
|
return Ok(());
|
|
}
|
|
session
|
|
};
|
|
|
|
let entry_index = session
|
|
.entries
|
|
.iter()
|
|
.position(|entry| entry.block_string() == prompt.entry);
|
|
let Some(entry_index) = entry_index else {
|
|
state
|
|
.sessions
|
|
.lock()
|
|
.await
|
|
.insert(prompt.session_id.clone(), session);
|
|
send_error(bot, chat_id, "Item not found.").await?;
|
|
let _ = bot
|
|
.delete_message(chat_id, prompt.prompt_message_id)
|
|
.await;
|
|
let _ = bot.delete_message(chat_id, message_id).await;
|
|
return Ok(());
|
|
};
|
|
|
|
let op = QueuedOp {
|
|
kind: QueuedOpKind::MoveToFinishedUpdated,
|
|
entry: prompt.entry.clone(),
|
|
resource_path: None,
|
|
updated_entry: Some(updated_entry.clone()),
|
|
};
|
|
|
|
match apply_user_op(state, &op).await? {
|
|
UserOpOutcome::Applied(ApplyOutcome::Applied) => {
|
|
session.entries.remove(entry_index);
|
|
session.view = prompt.return_to.clone();
|
|
let peeked_snapshot = state.peeked.lock().await.clone();
|
|
normalize_peek_view(&mut session, &peeked_snapshot);
|
|
send_ephemeral(bot, chat_id, "Moved.", ACK_TTL_SECS).await?;
|
|
let _ = add_undo(state, UndoKind::MoveToFinished, updated_entry).await?;
|
|
}
|
|
UserOpOutcome::Applied(ApplyOutcome::NotFound) => {
|
|
send_error(bot, chat_id, "Item not found.").await?;
|
|
}
|
|
UserOpOutcome::Applied(ApplyOutcome::Duplicate) => {}
|
|
UserOpOutcome::Queued => {
|
|
send_error(bot, chat_id, "Write failed; queued for retry.").await?;
|
|
}
|
|
}
|
|
|
|
let peeked_snapshot = state.peeked.lock().await.clone();
|
|
let (text, kb) = render_list_view(&session.id, &session, &peeked_snapshot, &state.config);
|
|
if let Some(list_message_id) = session.message_id {
|
|
bot.edit_message_text(chat_id, list_message_id, text)
|
|
.reply_markup(kb)
|
|
.await?;
|
|
} else {
|
|
let sent = bot.send_message(chat_id, text).reply_markup(kb).await?;
|
|
session.message_id = Some(sent.id);
|
|
}
|
|
if let Err(err) = send_embedded_media_for_view(bot, chat_id, state, &session, &peeked_snapshot).await
|
|
{
|
|
error!("send embedded media failed: {:#}", err);
|
|
}
|
|
state
|
|
.sessions
|
|
.lock()
|
|
.await
|
|
.insert(prompt.session_id.clone(), session);
|
|
state
|
|
.active_sessions
|
|
.lock()
|
|
.await
|
|
.insert(chat_id.0, prompt.session_id.clone());
|
|
|
|
let _ = bot
|
|
.delete_message(chat_id, prompt.prompt_message_id)
|
|
.await;
|
|
let _ = bot.delete_message(chat_id, message_id).await;
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_list_callback(
|
|
bot: Bot,
|
|
q: CallbackQuery,
|
|
state: std::sync::Arc<AppState>,
|
|
) -> Result<()> {
|
|
let Some(message) = q.message.clone() else {
|
|
return Ok(());
|
|
};
|
|
let Some(data) = q.data.as_deref() else {
|
|
return Ok(());
|
|
};
|
|
let mut parts = data.split(':');
|
|
let _ = parts.next();
|
|
let session_id = match parts.next() {
|
|
Some(id) => id.to_string(),
|
|
None => return Ok(()),
|
|
};
|
|
let action = match parts.next() {
|
|
Some(action) => action,
|
|
None => return Ok(()),
|
|
};
|
|
|
|
let chat_id = message.chat.id.0;
|
|
let mut session = {
|
|
let mut sessions = state.sessions.lock().await;
|
|
let session = match sessions.remove(&session_id) {
|
|
Some(session) => session,
|
|
None => {
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
if session.chat_id != chat_id {
|
|
sessions.insert(session_id.clone(), session);
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
session
|
|
};
|
|
|
|
let peeked_snapshot = state.peeked.lock().await.clone();
|
|
|
|
match action {
|
|
"menu" => {
|
|
if matches!(&session.kind, SessionKind::List) {
|
|
session.view = ListView::Menu;
|
|
}
|
|
}
|
|
"top" => {
|
|
let page = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
|
session.view = ListView::Peek {
|
|
mode: ListMode::Top,
|
|
page,
|
|
};
|
|
}
|
|
"bottom" => {
|
|
let page = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
|
session.view = ListView::Peek {
|
|
mode: ListMode::Bottom,
|
|
page,
|
|
};
|
|
}
|
|
"next" => {
|
|
if let ListView::Peek { mode, page } = session.view.clone() {
|
|
session.view = ListView::Peek {
|
|
mode,
|
|
page: page + 1,
|
|
};
|
|
}
|
|
}
|
|
"prev" => {
|
|
if let ListView::Peek { mode, page } = session.view.clone() {
|
|
session.view = ListView::Peek {
|
|
mode,
|
|
page: page.saturating_sub(1),
|
|
};
|
|
}
|
|
}
|
|
"back" => {
|
|
session.view = match session.view.clone() {
|
|
ListView::Selected { return_to, .. } => *return_to,
|
|
ListView::Peek { .. } => ListView::Menu,
|
|
other => other,
|
|
};
|
|
}
|
|
"close" => {
|
|
if matches!(&session.kind, SessionKind::Search { .. }) {
|
|
bot.delete_message(message.chat.id, message.id).await?;
|
|
let mut active = state.active_sessions.lock().await;
|
|
if active.get(&chat_id) == Some(&session.id) {
|
|
active.remove(&chat_id);
|
|
}
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
"random" => {
|
|
if matches!(&session.kind, SessionKind::List) {
|
|
if session.entries.is_empty() {
|
|
// Stay in place.
|
|
} else {
|
|
let mut remaining: Vec<usize> = (0..session.entries.len())
|
|
.filter(|i| !session.seen_random.contains(i))
|
|
.filter(|i| {
|
|
session
|
|
.entries
|
|
.get(*i)
|
|
.map(|entry| !peeked_snapshot.contains(&entry.block_string()))
|
|
.unwrap_or(false)
|
|
})
|
|
.collect();
|
|
if remaining.is_empty() {
|
|
send_ephemeral(
|
|
&bot,
|
|
message.chat.id,
|
|
"Everything's been peeked already.",
|
|
ACK_TTL_SECS,
|
|
)
|
|
.await?;
|
|
// Stay in place.
|
|
session.view = ListView::Menu;
|
|
} else {
|
|
let index = {
|
|
let mut rng = rand::thread_rng();
|
|
remaining.shuffle(&mut rng);
|
|
remaining.first().copied()
|
|
};
|
|
if let Some(index) = index {
|
|
session.seen_random.insert(index);
|
|
let return_to = Box::new(session.view.clone());
|
|
session.view = ListView::Selected { return_to, index };
|
|
if let Some(entry) = session.entries.get(index) {
|
|
state.peeked.lock().await.insert(entry.block_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"pick" => {
|
|
if let ListView::Peek { mode, page } = session.view.clone() {
|
|
let pick_index = parts.next().and_then(|p| p.parse::<usize>().ok());
|
|
if let Some(pick_index) = pick_index {
|
|
if let Some(entry_index) =
|
|
peek_indices_for_session(&session, &peeked_snapshot, mode, page)
|
|
.get(pick_index.saturating_sub(1))
|
|
.copied()
|
|
{
|
|
let return_to = Box::new(ListView::Peek { mode, page });
|
|
session.view = ListView::Selected {
|
|
return_to,
|
|
index: entry_index,
|
|
};
|
|
if matches!(&session.kind, SessionKind::List) {
|
|
if let Some(entry) = session.entries.get(entry_index) {
|
|
state.peeked.lock().await.insert(entry.block_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"finish" => {
|
|
if let ListView::Selected { index, .. } = session.view.clone() {
|
|
session.view = ListView::FinishConfirm {
|
|
selected: Box::new(session.view.clone()),
|
|
index,
|
|
};
|
|
}
|
|
}
|
|
"finish_now" => {
|
|
if let ListView::FinishConfirm { selected, index } = session.view.clone() {
|
|
let entry_block = session.entries.get(index).map(|e| e.block_string());
|
|
if let Some(entry_block) = entry_block {
|
|
let op = QueuedOp {
|
|
kind: QueuedOpKind::MoveToFinished,
|
|
entry: entry_block.clone(),
|
|
resource_path: None,
|
|
updated_entry: None,
|
|
};
|
|
match apply_user_op(&state, &op).await? {
|
|
UserOpOutcome::Applied(ApplyOutcome::Applied) => {
|
|
session.entries.remove(index);
|
|
if let ListView::Selected { return_to, .. } = *selected {
|
|
session.view = *return_to;
|
|
} else {
|
|
session.view = ListView::Menu;
|
|
}
|
|
normalize_peek_view(&mut session, &peeked_snapshot);
|
|
send_ephemeral(&bot, message.chat.id, "Moved.", ACK_TTL_SECS)
|
|
.await?;
|
|
let _ = add_undo(&state, UndoKind::MoveToFinished, entry_block).await?;
|
|
}
|
|
UserOpOutcome::Applied(ApplyOutcome::NotFound) => {
|
|
send_error(&bot, message.chat.id, "Item not found.").await?;
|
|
session.view = *selected;
|
|
}
|
|
UserOpOutcome::Applied(ApplyOutcome::Duplicate) => {
|
|
session.view = *selected;
|
|
}
|
|
UserOpOutcome::Queued => {
|
|
send_error(&bot, message.chat.id, "Write failed; queued for retry.")
|
|
.await?;
|
|
session.view = *selected;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"finish_title" => {
|
|
if let ListView::FinishConfirm { selected, index } = session.view.clone() {
|
|
let selected_view = *selected;
|
|
if let Some(entry) = session.entries.get(index) {
|
|
let text = entry.display_lines().join("\n");
|
|
let links = extract_links(&text);
|
|
if let Some(link) = links.first().cloned() {
|
|
let prompt_text = "Send a title for the finished item.";
|
|
let sent = bot.send_message(message.chat.id, prompt_text).await?;
|
|
let return_to = match selected_view.clone() {
|
|
ListView::Selected { return_to, .. } => *return_to,
|
|
_ => ListView::Menu,
|
|
};
|
|
let prompt = FinishTitlePrompt {
|
|
session_id: session.id.clone(),
|
|
chat_id,
|
|
entry: entry.block_string(),
|
|
link,
|
|
return_to,
|
|
prompt_message_id: sent.id,
|
|
expires_at: now_ts() + FINISH_TITLE_PROMPT_TTL_SECS,
|
|
};
|
|
let previous = state
|
|
.finish_title_prompts
|
|
.lock()
|
|
.await
|
|
.insert(chat_id, prompt);
|
|
if let Some(previous) = previous {
|
|
let _ = bot
|
|
.delete_message(message.chat.id, previous.prompt_message_id)
|
|
.await;
|
|
}
|
|
session.view = selected_view;
|
|
} else {
|
|
send_error(&bot, message.chat.id, "No link found for a title.").await?;
|
|
session.view = selected_view;
|
|
}
|
|
} else {
|
|
send_error(&bot, message.chat.id, "Item not found.").await?;
|
|
session.view = selected_view;
|
|
}
|
|
}
|
|
}
|
|
"finish_cancel" => {
|
|
if let ListView::FinishConfirm { selected, .. } = session.view.clone() {
|
|
session.view = *selected;
|
|
}
|
|
}
|
|
"resource" => {
|
|
if let ListView::Selected { index, .. } = session.view.clone() {
|
|
if let Some(entry) = session.entries.get(index) {
|
|
let text = entry.display_lines().join("\n");
|
|
start_resource_picker(&bot, message.chat.id, &state, &text, None).await?;
|
|
} else {
|
|
send_error(&bot, message.chat.id, "Item not found.").await?;
|
|
}
|
|
}
|
|
}
|
|
"delete" => {
|
|
if let ListView::Selected { index, .. } = session.view.clone() {
|
|
let expires_at = now_ts() + DELETE_CONFIRM_TTL_SECS;
|
|
session.view = ListView::DeleteConfirm {
|
|
selected: Box::new(session.view.clone()),
|
|
index,
|
|
step: 1,
|
|
expires_at,
|
|
};
|
|
}
|
|
}
|
|
"del1" => {
|
|
if let ListView::DeleteConfirm {
|
|
selected,
|
|
index,
|
|
step: _,
|
|
expires_at,
|
|
} = session.view.clone()
|
|
{
|
|
if now_ts() > expires_at {
|
|
session.view = *selected;
|
|
send_error(&bot, message.chat.id, "Delete confirmation expired.")
|
|
.await?;
|
|
} else {
|
|
session.view = ListView::DeleteConfirm {
|
|
selected,
|
|
index,
|
|
step: 2,
|
|
expires_at,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
"del2" => {
|
|
if let ListView::DeleteConfirm {
|
|
selected,
|
|
index,
|
|
step: _,
|
|
expires_at,
|
|
} = session.view.clone()
|
|
{
|
|
if now_ts() > expires_at {
|
|
session.view = *selected;
|
|
send_error(&bot, message.chat.id, "Delete confirmation expired.")
|
|
.await?;
|
|
} else {
|
|
let entry_block = session.entries.get(index).map(|e| e.block_string());
|
|
if let Some(entry_block) = entry_block {
|
|
let op = QueuedOp {
|
|
kind: QueuedOpKind::Delete,
|
|
entry: entry_block.clone(),
|
|
resource_path: None,
|
|
updated_entry: None,
|
|
};
|
|
match apply_user_op(&state, &op).await? {
|
|
UserOpOutcome::Applied(ApplyOutcome::Applied) => {
|
|
session.entries.remove(index);
|
|
if let ListView::Selected { return_to, .. } = *selected {
|
|
session.view = *return_to;
|
|
} else {
|
|
session.view = ListView::Menu;
|
|
}
|
|
normalize_peek_view(&mut session, &peeked_snapshot);
|
|
let _ = add_undo(&state, UndoKind::Delete, entry_block).await?;
|
|
}
|
|
UserOpOutcome::Applied(ApplyOutcome::NotFound) => {
|
|
send_error(&bot, message.chat.id, "Item not found.").await?;
|
|
session.view = *selected;
|
|
}
|
|
UserOpOutcome::Applied(ApplyOutcome::Duplicate) => {}
|
|
UserOpOutcome::Queued => {
|
|
send_error(
|
|
&bot,
|
|
message.chat.id,
|
|
"Write failed; queued for retry.",
|
|
)
|
|
.await?;
|
|
session.view = *selected;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"cancel_del" => {
|
|
if let ListView::DeleteConfirm { selected, .. } = session.view.clone() {
|
|
session.view = *selected;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
session.message_id = Some(message.id);
|
|
let (text, kb) = render_list_view(&session.id, &session, &peeked_snapshot, &state.config);
|
|
let session_clone = session.clone();
|
|
state
|
|
.sessions
|
|
.lock()
|
|
.await
|
|
.insert(session.id.clone(), session);
|
|
state
|
|
.active_sessions
|
|
.lock()
|
|
.await
|
|
.insert(chat_id, session_clone.id.clone());
|
|
bot.edit_message_text(message.chat.id, message.id, text)
|
|
.reply_markup(kb)
|
|
.await?;
|
|
if let Err(err) =
|
|
send_embedded_media_for_view(&bot, message.chat.id, &state, &session_clone, &peeked_snapshot)
|
|
.await
|
|
{
|
|
error!("send embedded media failed: {:#}", err);
|
|
}
|
|
bot.answer_callback_query(q.id).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_picker_callback(
|
|
bot: Bot,
|
|
q: CallbackQuery,
|
|
state: std::sync::Arc<AppState>,
|
|
) -> Result<()> {
|
|
let Some(message) = q.message.clone() else {
|
|
return Ok(());
|
|
};
|
|
let Some(data) = q.data.as_deref() else {
|
|
return Ok(());
|
|
};
|
|
let mut parts = data.split(':');
|
|
let _ = parts.next();
|
|
let picker_id = match parts.next() {
|
|
Some(id) => id.to_string(),
|
|
None => return Ok(()),
|
|
};
|
|
let action = match parts.next() {
|
|
Some(action) => action,
|
|
None => return Ok(()),
|
|
};
|
|
|
|
let mut picker = {
|
|
let mut pickers = state.pickers.lock().await;
|
|
let picker = match pickers.remove(&picker_id) {
|
|
Some(picker) => picker,
|
|
None => {
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
if picker.chat_id != message.chat.id.0 || picker.message_id != message.id {
|
|
pickers.insert(picker_id.clone(), picker);
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
picker
|
|
};
|
|
|
|
let mut reinsert = false;
|
|
|
|
match action {
|
|
"toggle" => {
|
|
if let Some(index) = parts.next().and_then(|p| p.parse::<usize>().ok()) {
|
|
if index < picker.selected.len() {
|
|
picker.selected[index] = !picker.selected[index];
|
|
}
|
|
}
|
|
let text = build_picker_text(&picker.items, &picker.selected);
|
|
let kb = build_picker_keyboard(&picker.id, &picker.selected);
|
|
bot.edit_message_text(message.chat.id, message.id, text)
|
|
.reply_markup(kb)
|
|
.await?;
|
|
reinsert = true;
|
|
}
|
|
"add" => {
|
|
let selected_items: Vec<String> = picker
|
|
.items
|
|
.iter()
|
|
.zip(picker.selected.iter())
|
|
.filter_map(|(item, selected)| if *selected { Some(item.clone()) } else { None })
|
|
.collect();
|
|
if selected_items.is_empty() {
|
|
bot.answer_callback_query(q.id)
|
|
.text("Select at least one item.")
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
|
|
let mut added = 0usize;
|
|
let mut duplicates = 0usize;
|
|
let mut queued = false;
|
|
for item in selected_items {
|
|
let entry = EntryBlock::from_text(&item);
|
|
let op = QueuedOp {
|
|
kind: QueuedOpKind::Add,
|
|
entry: entry.block_string(),
|
|
resource_path: None,
|
|
updated_entry: None,
|
|
};
|
|
match apply_user_op(&state, &op).await? {
|
|
UserOpOutcome::Applied(ApplyOutcome::Applied) => added += 1,
|
|
UserOpOutcome::Applied(ApplyOutcome::Duplicate) => duplicates += 1,
|
|
UserOpOutcome::Applied(ApplyOutcome::NotFound) => {}
|
|
UserOpOutcome::Queued => queued = true,
|
|
}
|
|
}
|
|
|
|
if queued {
|
|
send_error(&bot, message.chat.id, "Write failed; queued for retry.")
|
|
.await?;
|
|
}
|
|
|
|
let summary = if duplicates > 0 {
|
|
format!("Saved {} item(s); {} duplicate(s) skipped.", added, duplicates)
|
|
} else {
|
|
format!("Saved {} item(s).", added)
|
|
};
|
|
send_ephemeral(&bot, message.chat.id, &summary, ACK_TTL_SECS).await?;
|
|
if !queued {
|
|
let _ = bot
|
|
.delete_message(ChatId(picker.chat_id), picker.source_message_id)
|
|
.await;
|
|
}
|
|
bot.delete_message(message.chat.id, message.id).await?;
|
|
}
|
|
"cancel" => {
|
|
bot.delete_message(message.chat.id, message.id).await?;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
if reinsert {
|
|
state.pickers.lock().await.insert(picker_id, picker);
|
|
}
|
|
|
|
bot.answer_callback_query(q.id).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_undos_callback(
|
|
bot: Bot,
|
|
q: CallbackQuery,
|
|
state: std::sync::Arc<AppState>,
|
|
) -> Result<()> {
|
|
let Some(message) = q.message.clone() else {
|
|
return Ok(());
|
|
};
|
|
let Some(data) = q.data.as_deref() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let mut parts = data.split(':');
|
|
let _ = parts.next();
|
|
let session_id = match parts.next() {
|
|
Some(id) => id.to_string(),
|
|
None => return Ok(()),
|
|
};
|
|
let action = match parts.next() {
|
|
Some(action) => action,
|
|
None => return Ok(()),
|
|
};
|
|
|
|
let session = {
|
|
let mut sessions = state.undo_sessions.lock().await;
|
|
let session = match sessions.remove(&session_id) {
|
|
Some(session) => session,
|
|
None => {
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
if session.chat_id != message.chat.id.0 || session.message_id != message.id {
|
|
sessions.insert(session_id, session);
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
session
|
|
};
|
|
|
|
match action {
|
|
"close" => {
|
|
let _ = bot.delete_message(message.chat.id, message.id).await;
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
"undo" => {
|
|
let index = parts.next().and_then(|p| p.parse::<usize>().ok());
|
|
let Some(index) = index else {
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
};
|
|
let Some(record) = session.records.get(index).cloned() else {
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
};
|
|
let op = match record.kind {
|
|
UndoKind::MoveToFinished => QueuedOp {
|
|
kind: QueuedOpKind::MoveToReadLater,
|
|
entry: record.entry,
|
|
resource_path: None,
|
|
updated_entry: None,
|
|
},
|
|
UndoKind::Delete => QueuedOp {
|
|
kind: QueuedOpKind::Add,
|
|
entry: record.entry,
|
|
resource_path: None,
|
|
updated_entry: None,
|
|
},
|
|
};
|
|
|
|
let mut undo = state.undo.lock().await;
|
|
prune_undo(&mut undo);
|
|
undo.retain(|r| r.id != record.id);
|
|
save_undo(&state.undo_path, &undo)?;
|
|
|
|
match apply_user_op(&state, &op).await? {
|
|
UserOpOutcome::Applied(ApplyOutcome::Applied)
|
|
| UserOpOutcome::Applied(ApplyOutcome::Duplicate)
|
|
| UserOpOutcome::Applied(ApplyOutcome::NotFound) => {
|
|
send_ephemeral(&bot, message.chat.id, "Undone.", ACK_TTL_SECS).await?;
|
|
}
|
|
UserOpOutcome::Queued => {
|
|
send_error(&bot, message.chat.id, "Write failed; queued for retry.")
|
|
.await?;
|
|
}
|
|
}
|
|
}
|
|
"delete" => {
|
|
let index = parts.next().and_then(|p| p.parse::<usize>().ok());
|
|
let Some(index) = index else {
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
};
|
|
let Some(record) = session.records.get(index).cloned() else {
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
};
|
|
let mut undo = state.undo.lock().await;
|
|
prune_undo(&mut undo);
|
|
undo.retain(|r| r.id != record.id);
|
|
save_undo(&state.undo_path, &undo)?;
|
|
}
|
|
_ => {
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
let _ = bot.delete_message(message.chat.id, message.id).await;
|
|
bot.answer_callback_query(q.id).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_undo_callback(
|
|
bot: Bot,
|
|
q: CallbackQuery,
|
|
state: std::sync::Arc<AppState>,
|
|
) -> Result<()> {
|
|
let Some(data) = q.data.as_deref() else {
|
|
return Ok(());
|
|
};
|
|
let mut parts = data.trim_start_matches("undo:").split(':');
|
|
let undo_id = parts.next().unwrap_or("");
|
|
let action = parts.next().unwrap_or("undo");
|
|
|
|
let (record, undo_snapshot) = {
|
|
let mut undo = state.undo.lock().await;
|
|
prune_undo(&mut undo);
|
|
let pos = undo.iter().position(|r| r.id == undo_id);
|
|
let record = if let Some(pos) = pos {
|
|
Some(undo.remove(pos))
|
|
} else {
|
|
None
|
|
};
|
|
(record, undo.clone())
|
|
};
|
|
save_undo(&state.undo_path, &undo_snapshot)?;
|
|
|
|
if action == "delete" {
|
|
if let Some(message) = q.message.clone() {
|
|
bot.delete_message(message.chat.id, message.id).await?;
|
|
}
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
|
|
if let Some(record) = record {
|
|
let chat_id = chat_id_from_user_id(q.from.id.0);
|
|
if record.expires_at < now_ts() {
|
|
send_error(&bot, chat_id, "Undo expired.").await?;
|
|
bot.answer_callback_query(q.id).await?;
|
|
return Ok(());
|
|
}
|
|
|
|
let op = match record.kind {
|
|
UndoKind::MoveToFinished => QueuedOp {
|
|
kind: QueuedOpKind::MoveToReadLater,
|
|
entry: record.entry,
|
|
resource_path: None,
|
|
updated_entry: None,
|
|
},
|
|
UndoKind::Delete => QueuedOp {
|
|
kind: QueuedOpKind::Add,
|
|
entry: record.entry,
|
|
resource_path: None,
|
|
updated_entry: None,
|
|
},
|
|
};
|
|
|
|
match apply_user_op(&state, &op).await? {
|
|
UserOpOutcome::Applied(ApplyOutcome::Applied)
|
|
| UserOpOutcome::Applied(ApplyOutcome::Duplicate)
|
|
| UserOpOutcome::Applied(ApplyOutcome::NotFound) => {
|
|
send_ephemeral(&bot, chat_id, "Undone.", ACK_TTL_SECS).await?;
|
|
}
|
|
UserOpOutcome::Queued => {
|
|
send_error(&bot, chat_id, "Write failed; queued for retry.").await?;
|
|
}
|
|
}
|
|
if let Some(message) = q.message.clone() {
|
|
let _ = bot.delete_message(message.chat.id, message.id).await;
|
|
}
|
|
} else {
|
|
send_error(&bot, chat_id_from_user_id(q.from.id.0), "Undo not found.").await?;
|
|
}
|
|
|
|
bot.answer_callback_query(q.id).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn apply_user_op(state: &std::sync::Arc<AppState>, op: &QueuedOp) -> Result<UserOpOutcome> {
|
|
match apply_op(state, op).await {
|
|
Ok(outcome) => Ok(UserOpOutcome::Applied(outcome)),
|
|
Err(err) => {
|
|
error!("write failed: {:#}", err);
|
|
queue_op(state, op.clone()).await?;
|
|
Ok(UserOpOutcome::Queued)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn apply_op(state: &std::sync::Arc<AppState>, op: &QueuedOp) -> Result<ApplyOutcome> {
|
|
let _guard = state.write_lock.lock().await;
|
|
match op.kind {
|
|
QueuedOpKind::Add => {
|
|
let entry = EntryBlock::from_block(&op.entry);
|
|
let outcome = with_retries(|| add_entry_sync(&state.config.read_later_path, &entry))
|
|
.await?;
|
|
Ok(match outcome {
|
|
AddOutcome::Added => ApplyOutcome::Applied,
|
|
AddOutcome::Duplicate => ApplyOutcome::Duplicate,
|
|
})
|
|
}
|
|
QueuedOpKind::AddResource => {
|
|
let path = op
|
|
.resource_path
|
|
.as_ref()
|
|
.ok_or_else(|| anyhow!("missing resource path"))?;
|
|
let outcome = with_retries(|| add_resource_entry_sync(path, &op.entry)).await?;
|
|
Ok(match outcome {
|
|
AddOutcome::Added => ApplyOutcome::Applied,
|
|
AddOutcome::Duplicate => ApplyOutcome::Duplicate,
|
|
})
|
|
}
|
|
QueuedOpKind::Delete => {
|
|
let outcome = with_retries(|| {
|
|
delete_entry_sync(&state.config.read_later_path, &op.entry)
|
|
})
|
|
.await?;
|
|
Ok(match outcome {
|
|
ModifyOutcome::Applied => ApplyOutcome::Applied,
|
|
ModifyOutcome::NotFound => ApplyOutcome::NotFound,
|
|
})
|
|
}
|
|
QueuedOpKind::MoveToFinished => {
|
|
let outcome = with_retries(|| {
|
|
move_to_finished_sync(
|
|
&state.config.read_later_path,
|
|
&state.config.finished_path,
|
|
&op.entry,
|
|
)
|
|
})
|
|
.await?;
|
|
Ok(match outcome {
|
|
ModifyOutcome::Applied => ApplyOutcome::Applied,
|
|
ModifyOutcome::NotFound => ApplyOutcome::NotFound,
|
|
})
|
|
}
|
|
QueuedOpKind::MoveToFinishedUpdated => {
|
|
let updated_entry = op
|
|
.updated_entry
|
|
.as_ref()
|
|
.ok_or_else(|| anyhow!("missing updated entry"))?;
|
|
let outcome = with_retries(|| {
|
|
move_to_finished_updated_sync(
|
|
&state.config.read_later_path,
|
|
&state.config.finished_path,
|
|
&op.entry,
|
|
updated_entry,
|
|
)
|
|
})
|
|
.await?;
|
|
Ok(match outcome {
|
|
ModifyOutcome::Applied => ApplyOutcome::Applied,
|
|
ModifyOutcome::NotFound => ApplyOutcome::NotFound,
|
|
})
|
|
}
|
|
QueuedOpKind::MoveToReadLater => {
|
|
let outcome = with_retries(|| {
|
|
move_to_read_later_sync(
|
|
&state.config.read_later_path,
|
|
&state.config.finished_path,
|
|
&op.entry,
|
|
)
|
|
})
|
|
.await?;
|
|
Ok(match outcome {
|
|
ModifyOutcome::Applied => ApplyOutcome::Applied,
|
|
ModifyOutcome::NotFound => ApplyOutcome::NotFound,
|
|
})
|
|
}
|
|
QueuedOpKind::UpdateEntry => {
|
|
let updated_entry = op
|
|
.updated_entry
|
|
.as_ref()
|
|
.ok_or_else(|| anyhow!("missing updated entry"))?;
|
|
let updated_entry = EntryBlock::from_block(updated_entry);
|
|
let outcome = with_retries(|| {
|
|
update_entry_sync(&state.config.read_later_path, &op.entry, &updated_entry)
|
|
})
|
|
.await?;
|
|
Ok(match outcome {
|
|
ModifyOutcome::Applied => ApplyOutcome::Applied,
|
|
ModifyOutcome::NotFound => ApplyOutcome::NotFound,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum ApplyOutcome {
|
|
Applied,
|
|
Duplicate,
|
|
NotFound,
|
|
}
|
|
|
|
enum UserOpOutcome {
|
|
Applied(ApplyOutcome),
|
|
Queued,
|
|
}
|
|
|
|
enum PushOutcome {
|
|
NoChanges,
|
|
Pushed,
|
|
}
|
|
|
|
enum PullOutcome {
|
|
UpToDate,
|
|
Pulled,
|
|
}
|
|
|
|
enum PullMode {
|
|
FastForward,
|
|
Theirs,
|
|
}
|
|
|
|
enum SyncOutcome {
|
|
NoChanges,
|
|
Synced,
|
|
}
|
|
|
|
async fn queue_op(state: &std::sync::Arc<AppState>, op: QueuedOp) -> Result<()> {
|
|
let mut queue = state.queue.lock().await;
|
|
queue.push(op);
|
|
save_queue(&state.queue_path, &queue)
|
|
}
|
|
|
|
fn run_push(sync: &SyncConfig) -> Result<PushOutcome> {
|
|
ensure_git_available()?;
|
|
if !sync.repo_path.exists() {
|
|
return Err(anyhow!(
|
|
"Sync repo path not found: {}",
|
|
sync.repo_path.display()
|
|
));
|
|
}
|
|
|
|
let repo_check = run_git(
|
|
&sync.repo_path,
|
|
&["rev-parse", "--is-inside-work-tree"],
|
|
Vec::new(),
|
|
)?;
|
|
if !repo_check.status.success() || repo_check.stdout.trim() != "true" {
|
|
return Err(anyhow!(
|
|
"Sync repo path not found or not a git repository: {}",
|
|
sync.repo_path.display()
|
|
));
|
|
}
|
|
|
|
let token = read_token_file(&sync.token_file)?;
|
|
|
|
let remotes = git_remote_names(&sync.repo_path)?;
|
|
let remote = if remotes.iter().any(|name| name == "origin") {
|
|
"origin".to_string()
|
|
} else {
|
|
remotes
|
|
.first()
|
|
.cloned()
|
|
.ok_or_else(|| anyhow!("Git remote not configured."))?
|
|
};
|
|
let remote_url = git_remote_url(&sync.repo_path, &remote)?;
|
|
if !remote_url.starts_with("https://") {
|
|
return Err(anyhow!(
|
|
"Sync requires HTTPS remote for PAT auth. Remote is {}",
|
|
remote_url
|
|
));
|
|
}
|
|
|
|
let username = extract_https_username(&remote_url).unwrap_or_else(|| "x-access-token".to_string());
|
|
|
|
let status_output = run_git(&sync.repo_path, &["status", "--porcelain"], Vec::new())?;
|
|
if !status_output.status.success() {
|
|
return Err(anyhow!(format_git_error("git status", &status_output)));
|
|
}
|
|
if status_output.stdout.trim().is_empty() {
|
|
return Ok(PushOutcome::NoChanges);
|
|
}
|
|
|
|
let add_output = run_git(&sync.repo_path, &["add", "-A"], Vec::new())?;
|
|
if !add_output.status.success() {
|
|
return Err(anyhow!(format_git_error("git add", &add_output)));
|
|
}
|
|
|
|
let commit_message = sync_commit_message();
|
|
let commit_output = run_git(
|
|
&sync.repo_path,
|
|
&["commit", "-m", &commit_message],
|
|
Vec::new(),
|
|
)?;
|
|
if !commit_output.status.success() {
|
|
if is_nothing_to_commit(&commit_output) {
|
|
return Ok(PushOutcome::NoChanges);
|
|
}
|
|
return Err(anyhow!(format_git_error("git commit", &commit_output)));
|
|
}
|
|
|
|
let branch = git_current_branch(&sync.repo_path)?;
|
|
if branch == "HEAD" {
|
|
return Err(anyhow!("Sync failed: detached HEAD."));
|
|
}
|
|
|
|
let askpass = create_askpass_script()?;
|
|
let askpass_path = askpass.to_string_lossy().to_string();
|
|
let push_env = vec![
|
|
("GIT_TERMINAL_PROMPT", "0".to_string()),
|
|
("GIT_ASKPASS", askpass_path),
|
|
("GIT_SYNC_USERNAME", username),
|
|
("GIT_SYNC_PAT", token),
|
|
];
|
|
let push_output = run_git(
|
|
&sync.repo_path,
|
|
&["push", &remote, &format!("HEAD:refs/heads/{}", branch)],
|
|
push_env,
|
|
)?;
|
|
if !push_output.status.success() {
|
|
return Err(anyhow!(format_git_error("git push", &push_output)));
|
|
}
|
|
|
|
Ok(PushOutcome::Pushed)
|
|
}
|
|
|
|
fn run_pull(sync: &SyncConfig, mode: PullMode) -> Result<PullOutcome> {
|
|
ensure_git_available()?;
|
|
if !sync.repo_path.exists() {
|
|
return Err(anyhow!(
|
|
"Sync repo path not found: {}",
|
|
sync.repo_path.display()
|
|
));
|
|
}
|
|
|
|
let repo_check = run_git(
|
|
&sync.repo_path,
|
|
&["rev-parse", "--is-inside-work-tree"],
|
|
Vec::new(),
|
|
)?;
|
|
if !repo_check.status.success() || repo_check.stdout.trim() != "true" {
|
|
return Err(anyhow!(
|
|
"Sync repo path not found or not a git repository: {}",
|
|
sync.repo_path.display()
|
|
));
|
|
}
|
|
|
|
let token = read_token_file(&sync.token_file)?;
|
|
|
|
let remotes = git_remote_names(&sync.repo_path)?;
|
|
let remote = if remotes.iter().any(|name| name == "origin") {
|
|
"origin".to_string()
|
|
} else {
|
|
remotes
|
|
.first()
|
|
.cloned()
|
|
.ok_or_else(|| anyhow!("Git remote not configured."))?
|
|
};
|
|
let remote_url = git_remote_url(&sync.repo_path, &remote)?;
|
|
if !remote_url.starts_with("https://") {
|
|
return Err(anyhow!(
|
|
"Sync requires HTTPS remote for PAT auth. Remote is {}",
|
|
remote_url
|
|
));
|
|
}
|
|
|
|
let username =
|
|
extract_https_username(&remote_url).unwrap_or_else(|| "x-access-token".to_string());
|
|
|
|
let status_output = run_git(&sync.repo_path, &["status", "--porcelain"], Vec::new())?;
|
|
if !status_output.status.success() {
|
|
return Err(anyhow!(format_git_error("git status", &status_output)));
|
|
}
|
|
if !status_output.stdout.trim().is_empty() {
|
|
return Err(anyhow!(
|
|
"Working tree has uncommitted changes; commit or stash before pull."
|
|
));
|
|
}
|
|
|
|
let branch = git_current_branch(&sync.repo_path)?;
|
|
if branch == "HEAD" {
|
|
return Err(anyhow!("Sync failed: detached HEAD."));
|
|
}
|
|
|
|
let askpass = create_askpass_script()?;
|
|
let askpass_path = askpass.to_string_lossy().to_string();
|
|
let pull_env = vec![
|
|
("GIT_TERMINAL_PROMPT", "0".to_string()),
|
|
("GIT_ASKPASS", askpass_path),
|
|
("GIT_SYNC_USERNAME", username),
|
|
("GIT_SYNC_PAT", token),
|
|
];
|
|
|
|
let pull_args: Vec<String> = match mode {
|
|
PullMode::FastForward => vec![
|
|
"pull".to_string(),
|
|
"--ff-only".to_string(),
|
|
remote,
|
|
branch,
|
|
],
|
|
PullMode::Theirs => vec![
|
|
"pull".to_string(),
|
|
"--no-edit".to_string(),
|
|
"-X".to_string(),
|
|
"theirs".to_string(),
|
|
remote,
|
|
branch,
|
|
],
|
|
};
|
|
let pull_args_ref: Vec<&str> = pull_args.iter().map(|arg| arg.as_str()).collect();
|
|
let pull_output = run_git(&sync.repo_path, &pull_args_ref, pull_env)?;
|
|
if !pull_output.status.success() {
|
|
return Err(anyhow!(format_git_error("git pull", &pull_output)));
|
|
}
|
|
|
|
if is_already_up_to_date(&pull_output) {
|
|
Ok(PullOutcome::UpToDate)
|
|
} else {
|
|
Ok(PullOutcome::Pulled)
|
|
}
|
|
}
|
|
|
|
fn run_sync(sync: &SyncConfig) -> Result<SyncOutcome> {
|
|
ensure_git_available()?;
|
|
if !sync.repo_path.exists() {
|
|
return Err(anyhow!(
|
|
"Sync repo path not found: {}",
|
|
sync.repo_path.display()
|
|
));
|
|
}
|
|
|
|
let repo_check = run_git(
|
|
&sync.repo_path,
|
|
&["rev-parse", "--is-inside-work-tree"],
|
|
Vec::new(),
|
|
)?;
|
|
if !repo_check.status.success() || repo_check.stdout.trim() != "true" {
|
|
return Err(anyhow!(
|
|
"Sync repo path not found or not a git repository: {}",
|
|
sync.repo_path.display()
|
|
));
|
|
}
|
|
|
|
let token = read_token_file(&sync.token_file)?;
|
|
|
|
let remotes = git_remote_names(&sync.repo_path)?;
|
|
let remote = if remotes.iter().any(|name| name == "origin") {
|
|
"origin".to_string()
|
|
} else {
|
|
remotes
|
|
.first()
|
|
.cloned()
|
|
.ok_or_else(|| anyhow!("Git remote not configured."))?
|
|
};
|
|
let remote_url = git_remote_url(&sync.repo_path, &remote)?;
|
|
if !remote_url.starts_with("https://") {
|
|
return Err(anyhow!(
|
|
"Sync requires HTTPS remote for PAT auth. Remote is {}",
|
|
remote_url
|
|
));
|
|
}
|
|
|
|
let username =
|
|
extract_https_username(&remote_url).unwrap_or_else(|| "x-access-token".to_string());
|
|
|
|
let status_output = run_git(&sync.repo_path, &["status", "--porcelain"], Vec::new())?;
|
|
if !status_output.status.success() {
|
|
return Err(anyhow!(format_git_error("git status", &status_output)));
|
|
}
|
|
|
|
let add_output = run_git(&sync.repo_path, &["add", "-A"], Vec::new())?;
|
|
if !add_output.status.success() {
|
|
return Err(anyhow!(format_git_error("git add", &add_output)));
|
|
}
|
|
|
|
let commit_message = sync_commit_message();
|
|
let commit_output = run_git(
|
|
&sync.repo_path,
|
|
&["commit", "-m", &commit_message],
|
|
Vec::new(),
|
|
)?;
|
|
let did_commit = if commit_output.status.success() {
|
|
true
|
|
} else if is_nothing_to_commit(&commit_output) {
|
|
false
|
|
} else {
|
|
return Err(anyhow!(format_git_error("git commit", &commit_output)));
|
|
};
|
|
|
|
let branch = git_current_branch(&sync.repo_path)?;
|
|
if branch == "HEAD" {
|
|
return Err(anyhow!("Sync failed: detached HEAD."));
|
|
}
|
|
|
|
let askpass = create_askpass_script()?;
|
|
let askpass_path = askpass.to_string_lossy().to_string();
|
|
let auth_env = vec![
|
|
("GIT_TERMINAL_PROMPT", "0".to_string()),
|
|
("GIT_ASKPASS", askpass_path),
|
|
("GIT_SYNC_USERNAME", username),
|
|
("GIT_SYNC_PAT", token),
|
|
];
|
|
|
|
let pull_output = run_git(
|
|
&sync.repo_path,
|
|
&["pull", "--ff-only", &remote, &branch],
|
|
auth_env.clone(),
|
|
)?;
|
|
if !pull_output.status.success() {
|
|
return Err(anyhow!(format_git_error("git pull", &pull_output)));
|
|
}
|
|
let did_pull = !is_already_up_to_date(&pull_output);
|
|
|
|
let push_output = run_git(
|
|
&sync.repo_path,
|
|
&["push", &remote, &format!("HEAD:refs/heads/{}", branch)],
|
|
auth_env,
|
|
)?;
|
|
if !push_output.status.success() {
|
|
return Err(anyhow!(format_git_error("git push", &push_output)));
|
|
}
|
|
let did_push = !is_push_up_to_date(&push_output);
|
|
|
|
if did_commit || did_pull || did_push {
|
|
Ok(SyncOutcome::Synced)
|
|
} else {
|
|
Ok(SyncOutcome::NoChanges)
|
|
}
|
|
}
|
|
|
|
struct GitOutput {
|
|
status: std::process::ExitStatus,
|
|
stdout: String,
|
|
stderr: String,
|
|
}
|
|
|
|
fn run_git(repo_path: &Path, args: &[&str], envs: Vec<(&str, String)>) -> Result<GitOutput> {
|
|
let mut cmd = Command::new("git");
|
|
cmd.current_dir(repo_path).args(args);
|
|
for (key, value) in envs {
|
|
cmd.env(key, value);
|
|
}
|
|
let output = cmd
|
|
.output()
|
|
.with_context(|| format!("run git command: git {}", args.join(" ")))?;
|
|
Ok(GitOutput {
|
|
status: output.status,
|
|
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
|
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
|
})
|
|
}
|
|
|
|
fn ensure_git_available() -> Result<()> {
|
|
match Command::new("git").arg("--version").output() {
|
|
Ok(output) => {
|
|
if output.status.success() {
|
|
Ok(())
|
|
} else {
|
|
Err(anyhow!("Git unavailable: git --version failed."))
|
|
}
|
|
}
|
|
Err(_) => Err(anyhow!(
|
|
"Git is not available in PATH. Add git to the service path."
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn format_git_error(action: &str, output: &GitOutput) -> String {
|
|
let mut message = format!("{} failed.", action);
|
|
let stdout = output.stdout.trim();
|
|
let stderr = output.stderr.trim();
|
|
if !stdout.is_empty() {
|
|
message.push_str("\nstdout:\n");
|
|
message.push_str(stdout);
|
|
}
|
|
if !stderr.is_empty() {
|
|
message.push_str("\nstderr:\n");
|
|
message.push_str(stderr);
|
|
}
|
|
message
|
|
}
|
|
|
|
fn git_remote_names(repo_path: &Path) -> Result<Vec<String>> {
|
|
let output = run_git(repo_path, &["remote"], Vec::new())?;
|
|
if !output.status.success() {
|
|
return Err(anyhow!(format_git_error("git remote", &output)));
|
|
}
|
|
let names = output
|
|
.stdout
|
|
.lines()
|
|
.map(|line| line.trim().to_string())
|
|
.filter(|line| !line.is_empty())
|
|
.collect::<Vec<_>>();
|
|
Ok(names)
|
|
}
|
|
|
|
fn git_remote_url(repo_path: &Path, remote: &str) -> Result<String> {
|
|
let output = run_git(repo_path, &["remote", "get-url", remote], Vec::new())?;
|
|
if !output.status.success() {
|
|
return Err(anyhow!(format_git_error("git remote get-url", &output)));
|
|
}
|
|
Ok(output.stdout.trim().to_string())
|
|
}
|
|
|
|
fn git_current_branch(repo_path: &Path) -> Result<String> {
|
|
let output = run_git(repo_path, &["rev-parse", "--abbrev-ref", "HEAD"], Vec::new())?;
|
|
if !output.status.success() {
|
|
return Err(anyhow!(format_git_error("git rev-parse", &output)));
|
|
}
|
|
Ok(output.stdout.trim().to_string())
|
|
}
|
|
|
|
fn read_token_file(path: &Path) -> Result<String> {
|
|
let token = match fs::read_to_string(path) {
|
|
Ok(token) => token,
|
|
Err(_) => {
|
|
return Err(anyhow!("Sync requires PAT in settings.sync.token_file."));
|
|
}
|
|
};
|
|
let token = token.trim().to_string();
|
|
if token.is_empty() {
|
|
return Err(anyhow!("Sync requires PAT in settings.sync.token_file."));
|
|
}
|
|
Ok(token)
|
|
}
|
|
|
|
fn extract_https_username(remote_url: &str) -> Option<String> {
|
|
if !remote_url.starts_with("https://") {
|
|
return None;
|
|
}
|
|
let without_scheme = &remote_url["https://".len()..];
|
|
let slash_pos = without_scheme.find('/').unwrap_or(without_scheme.len());
|
|
let authority = &without_scheme[..slash_pos];
|
|
let userinfo = authority.split('@').next()?;
|
|
if !authority.contains('@') {
|
|
return None;
|
|
}
|
|
let username = userinfo.split(':').next().unwrap_or("");
|
|
if username.is_empty() {
|
|
None
|
|
} else {
|
|
Some(username.to_string())
|
|
}
|
|
}
|
|
|
|
fn is_nothing_to_commit(output: &GitOutput) -> bool {
|
|
let combined = format!("{}\n{}", output.stdout, output.stderr).to_lowercase();
|
|
combined.contains("nothing to commit")
|
|
|| combined.contains("no changes added to commit")
|
|
|| combined.contains("working tree clean")
|
|
}
|
|
|
|
fn is_already_up_to_date(output: &GitOutput) -> bool {
|
|
let combined = format!("{}\n{}", output.stdout, output.stderr).to_lowercase();
|
|
combined.contains("already up to date") || combined.contains("already up-to-date")
|
|
}
|
|
|
|
fn is_push_up_to_date(output: &GitOutput) -> bool {
|
|
let combined = format!("{}\n{}", output.stdout, output.stderr).to_lowercase();
|
|
combined.contains("everything up-to-date") || combined.contains("everything up to date")
|
|
}
|
|
|
|
fn parse_pull_mode(rest: &str) -> std::result::Result<PullMode, String> {
|
|
let option = rest.trim();
|
|
if option.is_empty() {
|
|
return Ok(PullMode::FastForward);
|
|
}
|
|
if option.eq_ignore_ascii_case("theirs") {
|
|
return Ok(PullMode::Theirs);
|
|
}
|
|
Err("Unknown pull option. Use /pull or /pull theirs.".to_string())
|
|
}
|
|
|
|
fn sync_commit_message() -> String {
|
|
format!("Bot sync {}", Local::now().format("%Y-%m-%d %H:%M:%S"))
|
|
}
|
|
|
|
fn create_askpass_script() -> Result<TempPath> {
|
|
let mut file = NamedTempFile::new().context("create askpass script")?;
|
|
file.write_all(
|
|
b"#!/bin/sh\ncase \"$1\" in\n*Username*) echo \"$GIT_SYNC_USERNAME\" ;;\n*Password*) echo \"$GIT_SYNC_PAT\" ;;\n*) echo \"\" ;;\nesac\n",
|
|
)
|
|
.context("write askpass script")?;
|
|
let mut perms = file.as_file().metadata()?.permissions();
|
|
perms.set_mode(0o700);
|
|
fs::set_permissions(file.path(), perms).context("chmod askpass script")?;
|
|
Ok(file.into_temp_path())
|
|
}
|
|
|
|
fn split_items(text: &str) -> Vec<String> {
|
|
text.split("---")
|
|
.map(|s| s.trim())
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| s.to_string())
|
|
.collect()
|
|
}
|
|
|
|
async fn download_and_send_link(bot: &Bot, chat_id: ChatId, link: &str) -> Result<()> {
|
|
let temp_dir = TempDir::new().context("create download temp dir")?;
|
|
let target_dir = temp_dir.path().to_path_buf();
|
|
let link = link.to_string();
|
|
let path = tokio::task::spawn_blocking(move || run_ytdlp_download(&target_dir, &link))
|
|
.await
|
|
.context("yt-dlp task failed")??;
|
|
bot.send_document(chat_id, InputFile::file(path)).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn download_and_save_link(
|
|
state: &std::sync::Arc<AppState>,
|
|
link: &str,
|
|
) -> Result<PathBuf> {
|
|
let target_dir = state.config.media_dir.clone();
|
|
fs::create_dir_all(&target_dir)
|
|
.with_context(|| format!("create media dir {}", target_dir.display()))?;
|
|
let link = link.to_string();
|
|
let path = tokio::task::spawn_blocking(move || run_ytdlp_download(&target_dir, &link))
|
|
.await
|
|
.context("yt-dlp task failed")??;
|
|
if !path.exists() {
|
|
return Err(anyhow!("Download completed but file is missing."));
|
|
}
|
|
Ok(path)
|
|
}
|
|
|
|
fn run_ytdlp_download(target_dir: &Path, link: &str) -> Result<PathBuf> {
|
|
let template = target_dir.join("%(title).200B-%(id)s.%(ext)s");
|
|
let output = Command::new("yt-dlp")
|
|
.arg("--no-playlist")
|
|
.arg("--print")
|
|
.arg("after_move:filepath")
|
|
.arg("-o")
|
|
.arg(template.to_string_lossy().to_string())
|
|
.arg(link)
|
|
.output()
|
|
.context("run yt-dlp")?;
|
|
if !output.status.success() {
|
|
return Err(anyhow!(format_ytdlp_error(&output)));
|
|
}
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let path_line = stdout
|
|
.lines()
|
|
.rev()
|
|
.find(|line| !line.trim().is_empty())
|
|
.ok_or_else(|| anyhow!("yt-dlp did not return a filepath"))?;
|
|
let mut path = PathBuf::from(path_line.trim());
|
|
if path.is_relative() {
|
|
path = target_dir.join(path);
|
|
}
|
|
if !path.exists() {
|
|
return Err(anyhow!("yt-dlp output not found: {}", path.display()));
|
|
}
|
|
Ok(path)
|
|
}
|
|
|
|
fn format_ytdlp_error(output: &std::process::Output) -> String {
|
|
let mut message = "yt-dlp failed.".to_string();
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
if !stdout.is_empty() {
|
|
message.push_str("\nstdout:\n");
|
|
message.push_str(&stdout);
|
|
}
|
|
if !stderr.is_empty() {
|
|
message.push_str("\nstderr:\n");
|
|
message.push_str(&stderr);
|
|
}
|
|
message
|
|
}
|
|
|
|
fn search_entries(entries: &[EntryBlock], query: &str) -> Vec<EntryBlock> {
|
|
entries
|
|
.iter()
|
|
.filter(|entry| matches_query(entry, query))
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
fn matches_query(entry: &EntryBlock, query: &str) -> bool {
|
|
let needle = query.trim().to_lowercase();
|
|
if needle.is_empty() {
|
|
return false;
|
|
}
|
|
let haystack = entry.display_lines().join("\n").to_lowercase();
|
|
needle
|
|
.split_whitespace()
|
|
.all(|term| haystack.contains(term))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn displayed_indices_for_view(
|
|
session: &ListSession,
|
|
peeked: &HashSet<String>,
|
|
) -> Vec<usize> {
|
|
match session.view {
|
|
ListView::Peek { mode, page } => peek_indices_for_session(session, peeked, mode, page),
|
|
ListView::Selected { index, .. } => vec![index],
|
|
ListView::FinishConfirm { index, .. } => vec![index],
|
|
ListView::DeleteConfirm { index, .. } => vec![index],
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn embedded_lines_for_view(session: &ListSession, peeked: &HashSet<String>) -> Vec<String> {
|
|
match session.view {
|
|
ListView::Peek { mode, page } => peek_indices_for_session(session, peeked, mode, page)
|
|
.into_iter()
|
|
.filter_map(|index| session.entries.get(index))
|
|
.flat_map(|entry| entry.preview_lines())
|
|
.collect(),
|
|
ListView::Selected { index, .. } => session
|
|
.entries
|
|
.get(index)
|
|
.map(|entry| entry.display_lines())
|
|
.unwrap_or_default(),
|
|
ListView::FinishConfirm { index, .. } | ListView::DeleteConfirm { index, .. } => session
|
|
.entries
|
|
.get(index)
|
|
.map(|entry| entry.preview_lines())
|
|
.unwrap_or_default(),
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn norm_target_index(session: &ListSession, peeked: &HashSet<String>) -> Option<usize> {
|
|
match &session.view {
|
|
ListView::Selected { index, .. } => Some(*index),
|
|
ListView::FinishConfirm { index, .. } => Some(*index),
|
|
ListView::Peek { mode, page } => {
|
|
let indices = peek_indices_for_session(session, peeked, *mode, *page);
|
|
if indices.len() == 1 {
|
|
indices.first().copied()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn normalize_entry_markdown_links(entry: &EntryBlock) -> Option<EntryBlock> {
|
|
let mut changed = false;
|
|
let mut lines = Vec::with_capacity(entry.lines.len());
|
|
for line in &entry.lines {
|
|
let (normalized, line_changed) = normalize_markdown_links(line);
|
|
if line_changed {
|
|
changed = true;
|
|
}
|
|
lines.push(normalized);
|
|
}
|
|
if changed {
|
|
Some(EntryBlock { lines })
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn normalize_markdown_links(text: &str) -> (String, bool) {
|
|
if !text.contains('[') {
|
|
return (text.to_string(), false);
|
|
}
|
|
|
|
let mut out = String::with_capacity(text.len());
|
|
let mut index = 0;
|
|
let mut changed = false;
|
|
|
|
while let Some(start_rel) = text[index..].find('[') {
|
|
let start = index + start_rel;
|
|
out.push_str(&text[index..start]);
|
|
|
|
let label_start = start + 1;
|
|
let Some(label_end_rel) = text[label_start..].find(']') else {
|
|
out.push_str(&text[start..]);
|
|
return (out, changed);
|
|
};
|
|
let label_end = label_start + label_end_rel;
|
|
let after_label = label_end + 1;
|
|
if after_label >= text.len() || !text[after_label..].starts_with('(') {
|
|
out.push_str(&text[start..after_label]);
|
|
index = after_label;
|
|
continue;
|
|
}
|
|
|
|
let url_start = after_label + 1;
|
|
let Some(url_end_rel) = text[url_start..].find(')') else {
|
|
out.push_str(&text[start..]);
|
|
return (out, changed);
|
|
};
|
|
let url_end = url_start + url_end_rel;
|
|
out.push_str(&text[url_start..url_end]);
|
|
changed = true;
|
|
index = url_end + 1;
|
|
}
|
|
|
|
out.push_str(&text[index..]);
|
|
(out, changed)
|
|
}
|
|
|
|
fn extract_links(text: &str) -> Vec<String> {
|
|
let mut links = Vec::new();
|
|
let mut seen: HashSet<String> = HashSet::new();
|
|
|
|
let mut index = 0;
|
|
while let Some(start_rel) = text[index..].find('[') {
|
|
let start = index + start_rel;
|
|
let label_start = start + 1;
|
|
let Some(label_end_rel) = text[label_start..].find(']') else {
|
|
break;
|
|
};
|
|
let label_end = label_start + label_end_rel;
|
|
let after_label = label_end + 1;
|
|
if after_label >= text.len() || !text[after_label..].starts_with('(') {
|
|
index = after_label;
|
|
continue;
|
|
}
|
|
let url_start = after_label + 1;
|
|
let Some(url_end_rel) = text[url_start..].find(')') else {
|
|
break;
|
|
};
|
|
let url_end = url_start + url_end_rel;
|
|
let url = text[url_start..url_end].trim();
|
|
if is_http_link(url) {
|
|
push_link(&mut links, &mut seen, url.to_string());
|
|
}
|
|
index = url_end + 1;
|
|
}
|
|
|
|
let mut scan = 0;
|
|
while scan < text.len() {
|
|
let slice = &text[scan..];
|
|
let http_pos = slice.find("http://");
|
|
let https_pos = slice.find("https://");
|
|
let pos = match (http_pos, https_pos) {
|
|
(Some(a), Some(b)) => Some(a.min(b)),
|
|
(Some(a), None) => Some(a),
|
|
(None, Some(b)) => Some(b),
|
|
(None, None) => None,
|
|
};
|
|
let Some(pos) = pos else {
|
|
break;
|
|
};
|
|
let start = scan + pos;
|
|
let rest = &text[start..];
|
|
let end_rel = rest
|
|
.find(|c: char| c.is_whitespace())
|
|
.unwrap_or(rest.len());
|
|
let end = start + end_rel;
|
|
let mut url = text[start..end].to_string();
|
|
url = trim_link(&url);
|
|
if is_http_link(&url) {
|
|
push_link(&mut links, &mut seen, url);
|
|
}
|
|
scan = end;
|
|
}
|
|
|
|
links
|
|
}
|
|
|
|
fn is_http_link(link: &str) -> bool {
|
|
link.starts_with("http://") || link.starts_with("https://")
|
|
}
|
|
|
|
fn push_link(links: &mut Vec<String>, seen: &mut HashSet<String>, link: String) {
|
|
if seen.insert(link.clone()) {
|
|
links.push(link);
|
|
}
|
|
}
|
|
|
|
fn trim_link(link: &str) -> String {
|
|
link.trim()
|
|
.trim_end_matches(|c: char| ")]}>\"'.,;:!?".contains(c))
|
|
.to_string()
|
|
}
|
|
|
|
fn entry_with_title(entry: &str, title: &str, link: &str) -> String {
|
|
let mut entry = EntryBlock::from_block(entry);
|
|
let line = format!("- [{}]({})", title.trim(), link);
|
|
if entry.lines.is_empty() {
|
|
entry.lines.push(line);
|
|
} else {
|
|
entry.lines[0] = line;
|
|
}
|
|
entry.block_string()
|
|
}
|
|
|
|
fn build_picker_text(items: &[String], selected: &[bool]) -> String {
|
|
let mut text = String::from("Select items to save:\n\n");
|
|
for (idx, item) in items.iter().enumerate() {
|
|
let marker = if selected.get(idx).copied().unwrap_or(false) {
|
|
"[x]"
|
|
} else {
|
|
"[ ]"
|
|
};
|
|
let preview = preview_text(item);
|
|
text.push_str(&format!("{} {}\n", idx + 1, marker));
|
|
if let Some(first) = preview.get(0) {
|
|
text.push_str(&format!("{}\n", first));
|
|
}
|
|
if let Some(second) = preview.get(1) {
|
|
text.push_str(&format!("{}\n", second));
|
|
}
|
|
text.push('\n');
|
|
}
|
|
text.trim_end().to_string()
|
|
}
|
|
|
|
fn build_picker_keyboard(picker_id: &str, selected: &[bool]) -> InlineKeyboardMarkup {
|
|
let mut rows = Vec::new();
|
|
for (idx, is_selected) in selected.iter().enumerate() {
|
|
let label = if *is_selected {
|
|
format!("{} [x]", idx + 1)
|
|
} else {
|
|
format!("{} [ ]", idx + 1)
|
|
};
|
|
let data = format!("pick:{}:toggle:{}", picker_id, idx);
|
|
rows.push(vec![InlineKeyboardButton::callback(label, data)]);
|
|
}
|
|
rows.push(vec![
|
|
InlineKeyboardButton::callback(
|
|
"Add selected",
|
|
format!("pick:{}:add", picker_id),
|
|
),
|
|
InlineKeyboardButton::callback("Cancel", format!("pick:{}:cancel", picker_id)),
|
|
]);
|
|
InlineKeyboardMarkup::new(rows)
|
|
}
|
|
|
|
fn build_add_prompt_keyboard(prompt_id: &str) -> InlineKeyboardMarkup {
|
|
InlineKeyboardMarkup::new(vec![
|
|
vec![
|
|
InlineKeyboardButton::callback(
|
|
"Reading list",
|
|
format!("add:{}:normal", prompt_id),
|
|
),
|
|
InlineKeyboardButton::callback("Resource", format!("add:{}:resource", prompt_id)),
|
|
],
|
|
vec![InlineKeyboardButton::callback(
|
|
"Cancel",
|
|
format!("add:{}:cancel", prompt_id),
|
|
)],
|
|
])
|
|
}
|
|
|
|
fn build_resource_picker_keyboard(
|
|
picker_id: &str,
|
|
files: &[PathBuf],
|
|
) -> InlineKeyboardMarkup {
|
|
let mut rows: Vec<Vec<InlineKeyboardButton>> = Vec::new();
|
|
let mut current_row = Vec::new();
|
|
for (idx, path) in files.iter().enumerate() {
|
|
let label = path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.map(|name| name.to_string())
|
|
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
|
current_row.push(InlineKeyboardButton::callback(
|
|
label,
|
|
format!("res:{}:file:{}", picker_id, idx),
|
|
));
|
|
if current_row.len() == 2 {
|
|
rows.push(std::mem::take(&mut current_row));
|
|
}
|
|
}
|
|
if !current_row.is_empty() {
|
|
rows.push(current_row);
|
|
}
|
|
rows.push(vec![InlineKeyboardButton::callback(
|
|
"New file",
|
|
format!("res:{}:new", picker_id),
|
|
)]);
|
|
rows.push(vec![InlineKeyboardButton::callback(
|
|
"Cancel",
|
|
format!("res:{}:cancel", picker_id),
|
|
)]);
|
|
InlineKeyboardMarkup::new(rows)
|
|
}
|
|
|
|
fn build_download_picker_text(links: &[String]) -> String {
|
|
if links.is_empty() {
|
|
return "No links found. Add one?".to_string();
|
|
}
|
|
let mut text = String::from("Links:\n\n");
|
|
for (idx, link) in links.iter().enumerate() {
|
|
text.push_str(&format!("{}: {}\n", idx + 1, link));
|
|
}
|
|
text.trim_end().to_string()
|
|
}
|
|
|
|
fn build_download_picker_keyboard(
|
|
picker_id: &str,
|
|
links: &[String],
|
|
) -> InlineKeyboardMarkup {
|
|
let mut rows = Vec::new();
|
|
for (idx, _) in links.iter().enumerate() {
|
|
rows.push(vec![
|
|
InlineKeyboardButton::callback(
|
|
format!("Send {}", idx + 1),
|
|
format!("dl:{}:send:{}", picker_id, idx),
|
|
),
|
|
InlineKeyboardButton::callback(
|
|
format!("Save {}", idx + 1),
|
|
format!("dl:{}:save:{}", picker_id, idx),
|
|
),
|
|
]);
|
|
}
|
|
rows.push(vec![InlineKeyboardButton::callback(
|
|
"Add link",
|
|
format!("dl:{}:add", picker_id),
|
|
)]);
|
|
rows.push(vec![InlineKeyboardButton::callback(
|
|
"Cancel",
|
|
format!("dl:{}:cancel", picker_id),
|
|
)]);
|
|
InlineKeyboardMarkup::new(rows)
|
|
}
|
|
|
|
fn render_list_view(
|
|
session_id: &str,
|
|
session: &ListSession,
|
|
peeked: &HashSet<String>,
|
|
config: &Config,
|
|
) -> (String, InlineKeyboardMarkup) {
|
|
match &session.view {
|
|
ListView::Menu => build_menu_view(session_id, session),
|
|
ListView::Peek { mode, page } => {
|
|
build_peek_view(session_id, session, *mode, *page, peeked, config)
|
|
}
|
|
ListView::Selected { index, .. } => build_selected_view(session_id, session, *index, config),
|
|
ListView::FinishConfirm { index, .. } => {
|
|
build_finish_confirm_view(session_id, session, *index, config)
|
|
}
|
|
ListView::DeleteConfirm { step, index, .. } => {
|
|
build_delete_confirm_view(session_id, session, *index, *step, config)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_menu_view(session_id: &str, session: &ListSession) -> (String, InlineKeyboardMarkup) {
|
|
let count = session.entries.len();
|
|
match &session.kind {
|
|
SessionKind::List => {
|
|
let text = if count == 0 {
|
|
"Read Later is empty.".to_string()
|
|
} else {
|
|
"Choose Top, Bottom, or Random.".to_string()
|
|
};
|
|
|
|
let mut rows = Vec::new();
|
|
if count > 0 {
|
|
rows.push(vec![
|
|
InlineKeyboardButton::callback(
|
|
format!("Top ({})", count),
|
|
format!("ls:{}:top:0", session_id),
|
|
),
|
|
InlineKeyboardButton::callback(
|
|
format!("Bottom ({})", count),
|
|
format!("ls:{}:bottom:0", session_id),
|
|
),
|
|
]);
|
|
rows.push(vec![InlineKeyboardButton::callback(
|
|
"Random",
|
|
format!("ls:{}:random", session_id),
|
|
)]);
|
|
}
|
|
|
|
(text, InlineKeyboardMarkup::new(rows))
|
|
}
|
|
SessionKind::Search { query } => {
|
|
let text = if count == 0 {
|
|
format!("No matches for \"{}\".", query)
|
|
} else {
|
|
format!("Matches for \"{}\" ({}).", query, count)
|
|
};
|
|
|
|
let mut rows = Vec::new();
|
|
if count > 0 {
|
|
rows.push(vec![InlineKeyboardButton::callback(
|
|
"Show",
|
|
format!("ls:{}:top:0", session_id),
|
|
)]);
|
|
}
|
|
rows.push(vec![InlineKeyboardButton::callback(
|
|
"Close",
|
|
format!("ls:{}:close", session_id),
|
|
)]);
|
|
|
|
(text, InlineKeyboardMarkup::new(rows))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_peek_view(
|
|
session_id: &str,
|
|
session: &ListSession,
|
|
mode: ListMode,
|
|
page: usize,
|
|
peeked: &HashSet<String>,
|
|
config: &Config,
|
|
) -> (String, InlineKeyboardMarkup) {
|
|
let total_unpeeked = count_visible_entries(session, peeked);
|
|
let indices = peek_indices_for_session(session, peeked, mode, page);
|
|
let total_pages = if total_unpeeked == 0 {
|
|
0
|
|
} else {
|
|
(total_unpeeked + PAGE_SIZE - 1) / PAGE_SIZE
|
|
};
|
|
let mut text = match &session.kind {
|
|
SessionKind::List => {
|
|
let title = match mode {
|
|
ListMode::Top => "Top view",
|
|
ListMode::Bottom => "Bottom view",
|
|
};
|
|
let page_display = if total_pages == 0 { 0 } else { page + 1 };
|
|
format!("{} (page {})\n", title, page_display)
|
|
}
|
|
SessionKind::Search { query } => {
|
|
if total_pages > 0 {
|
|
format!("Matches for \"{}\" (page {}/{})\n", query, page + 1, total_pages)
|
|
} else {
|
|
format!("Matches for \"{}\"\n", query)
|
|
}
|
|
}
|
|
};
|
|
if total_unpeeked == 0 {
|
|
text.push_str("Everything's been peeked already.");
|
|
} else if indices.is_empty() {
|
|
text.push_str("No items on this page.");
|
|
} else {
|
|
for (display_index, entry_index) in indices.iter().enumerate() {
|
|
if let Some(entry) = session.entries.get(*entry_index) {
|
|
let preview = format_embedded_references_for_lines(&entry.preview_lines(), config);
|
|
text.push_str(&format!("{}) ", display_index + 1));
|
|
if let Some(first) = preview.get(0) {
|
|
text.push_str(first);
|
|
}
|
|
text.push('\n');
|
|
if let Some(second) = preview.get(1) {
|
|
text.push_str(" ");
|
|
text.push_str(second);
|
|
text.push('\n');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut rows = Vec::new();
|
|
if !indices.is_empty() {
|
|
let mut pick_row = Vec::new();
|
|
for i in 0..indices.len() {
|
|
pick_row.push(InlineKeyboardButton::callback(
|
|
format!("{}", i + 1),
|
|
format!("ls:{}:pick:{}", session_id, i + 1),
|
|
));
|
|
}
|
|
rows.push(pick_row);
|
|
}
|
|
|
|
rows.push(vec![
|
|
InlineKeyboardButton::callback("Prev", format!("ls:{}:prev", session_id)),
|
|
InlineKeyboardButton::callback("Next", format!("ls:{}:next", session_id)),
|
|
]);
|
|
match &session.kind {
|
|
SessionKind::List => {
|
|
rows.push(vec![
|
|
InlineKeyboardButton::callback("Back", format!("ls:{}:back", session_id)),
|
|
InlineKeyboardButton::callback("Random", format!("ls:{}:random", session_id)),
|
|
]);
|
|
}
|
|
SessionKind::Search { .. } => {
|
|
rows.push(vec![InlineKeyboardButton::callback(
|
|
"Close",
|
|
format!("ls:{}:close", session_id),
|
|
)]);
|
|
}
|
|
}
|
|
|
|
(text.trim_end().to_string(), InlineKeyboardMarkup::new(rows))
|
|
}
|
|
|
|
fn build_selected_view(
|
|
session_id: &str,
|
|
session: &ListSession,
|
|
index: usize,
|
|
config: &Config,
|
|
) -> (String, InlineKeyboardMarkup) {
|
|
let entry = session.entries.get(index);
|
|
let text = if let Some(entry) = entry {
|
|
let lines = format_embedded_references_for_lines(&entry.display_lines(), config);
|
|
format!("Selected item:\n\n{}", lines.join("\n"))
|
|
} else {
|
|
"Selected item not found.".to_string()
|
|
};
|
|
|
|
let rows = match &session.kind {
|
|
SessionKind::List => vec![
|
|
vec![
|
|
InlineKeyboardButton::callback("Mark Finished", format!("ls:{}:finish", session_id)),
|
|
InlineKeyboardButton::callback(
|
|
"Add Resource",
|
|
format!("ls:{}:resource", session_id),
|
|
),
|
|
],
|
|
vec![
|
|
InlineKeyboardButton::callback(
|
|
"Delete",
|
|
format!("ls:{}:delete", session_id),
|
|
),
|
|
InlineKeyboardButton::callback(
|
|
"Random",
|
|
format!("ls:{}:random", session_id),
|
|
),
|
|
],
|
|
vec![InlineKeyboardButton::callback(
|
|
"Back",
|
|
format!("ls:{}:back", session_id),
|
|
)],
|
|
],
|
|
SessionKind::Search { .. } => vec![
|
|
vec![InlineKeyboardButton::callback(
|
|
"Add Resource",
|
|
format!("ls:{}:resource", session_id),
|
|
)],
|
|
vec![InlineKeyboardButton::callback(
|
|
"Delete",
|
|
format!("ls:{}:delete", session_id),
|
|
)],
|
|
vec![InlineKeyboardButton::callback(
|
|
"Back",
|
|
format!("ls:{}:back", session_id),
|
|
)],
|
|
],
|
|
};
|
|
|
|
(text, InlineKeyboardMarkup::new(rows))
|
|
}
|
|
|
|
fn build_undos_view(session_id: &str, records: &[UndoRecord]) -> (String, InlineKeyboardMarkup) {
|
|
let mut text = format!("Undos ({})\n\n", records.len());
|
|
for (idx, record) in records.iter().enumerate() {
|
|
let label = match record.kind {
|
|
UndoKind::MoveToFinished => "Moved to finished",
|
|
UndoKind::Delete => "Deleted",
|
|
};
|
|
text.push_str(&format!("{}) {}\n", idx + 1, label));
|
|
let preview = undo_preview(&record.entry);
|
|
if let Some(first) = preview.get(0) {
|
|
text.push_str(" ");
|
|
text.push_str(first);
|
|
text.push('\n');
|
|
}
|
|
if let Some(second) = preview.get(1) {
|
|
text.push_str(" ");
|
|
text.push_str(second);
|
|
text.push('\n');
|
|
}
|
|
text.push('\n');
|
|
}
|
|
|
|
let mut rows = Vec::new();
|
|
for (idx, _) in records.iter().enumerate() {
|
|
rows.push(vec![
|
|
InlineKeyboardButton::callback(
|
|
format!("Undo {}", idx + 1),
|
|
format!("undos:{}:undo:{}", session_id, idx),
|
|
),
|
|
InlineKeyboardButton::callback(
|
|
format!("Delete {}", idx + 1),
|
|
format!("undos:{}:delete:{}", session_id, idx),
|
|
),
|
|
]);
|
|
}
|
|
rows.push(vec![InlineKeyboardButton::callback(
|
|
"Close",
|
|
format!("undos:{}:close", session_id),
|
|
)]);
|
|
|
|
(text.trim_end().to_string(), InlineKeyboardMarkup::new(rows))
|
|
}
|
|
|
|
fn build_finish_confirm_view(
|
|
session_id: &str,
|
|
session: &ListSession,
|
|
index: usize,
|
|
config: &Config,
|
|
) -> (String, InlineKeyboardMarkup) {
|
|
let entry = session.entries.get(index);
|
|
let preview = entry
|
|
.map(|e| format_embedded_references_for_lines(&e.preview_lines(), config))
|
|
.unwrap_or_default();
|
|
let mut text = String::from("Finish this item?\n\n");
|
|
if let Some(first) = preview.get(0) {
|
|
text.push_str(first);
|
|
text.push('\n');
|
|
}
|
|
if let Some(second) = preview.get(1) {
|
|
text.push_str(second);
|
|
text.push('\n');
|
|
}
|
|
|
|
let rows = vec![
|
|
vec![InlineKeyboardButton::callback(
|
|
"Finish",
|
|
format!("ls:{}:finish_now", session_id),
|
|
)],
|
|
vec![InlineKeyboardButton::callback(
|
|
"Finish + Title",
|
|
format!("ls:{}:finish_title", session_id),
|
|
)],
|
|
vec![InlineKeyboardButton::callback(
|
|
"Cancel",
|
|
format!("ls:{}:finish_cancel", session_id),
|
|
)],
|
|
];
|
|
|
|
(text.trim_end().to_string(), InlineKeyboardMarkup::new(rows))
|
|
}
|
|
|
|
fn build_delete_confirm_view(
|
|
session_id: &str,
|
|
session: &ListSession,
|
|
index: usize,
|
|
step: u8,
|
|
config: &Config,
|
|
) -> (String, InlineKeyboardMarkup) {
|
|
let entry = session.entries.get(index);
|
|
let preview = entry
|
|
.map(|e| format_embedded_references_for_lines(&e.preview_lines(), config))
|
|
.unwrap_or_default();
|
|
let mut text = format!("Confirm delete ({}/2)?\n\n", step);
|
|
if let Some(first) = preview.get(0) {
|
|
text.push_str(first);
|
|
text.push('\n');
|
|
}
|
|
if let Some(second) = preview.get(1) {
|
|
text.push_str(second);
|
|
text.push('\n');
|
|
}
|
|
|
|
let confirm_action = if step == 1 { "del1" } else { "del2" };
|
|
let rows = vec![
|
|
vec![InlineKeyboardButton::callback(
|
|
"Confirm",
|
|
format!("ls:{}:{}", session_id, confirm_action),
|
|
)],
|
|
vec![InlineKeyboardButton::callback(
|
|
"Cancel",
|
|
format!("ls:{}:cancel_del", session_id),
|
|
)],
|
|
];
|
|
|
|
(text.trim_end().to_string(), InlineKeyboardMarkup::new(rows))
|
|
}
|
|
|
|
fn count_unpeeked_entries(entries: &[EntryBlock], peeked: &HashSet<String>) -> usize {
|
|
entries
|
|
.iter()
|
|
.filter(|entry| !peeked.contains(&entry.block_string()))
|
|
.count()
|
|
}
|
|
|
|
fn count_visible_entries(session: &ListSession, peeked: &HashSet<String>) -> usize {
|
|
match session.kind {
|
|
SessionKind::Search { .. } => session.entries.len(),
|
|
SessionKind::List => count_unpeeked_entries(&session.entries, peeked),
|
|
}
|
|
}
|
|
|
|
fn ordered_unpeeked_indices(
|
|
entries: &[EntryBlock],
|
|
peeked: &HashSet<String>,
|
|
mode: ListMode,
|
|
) -> Vec<usize> {
|
|
let mut indices: Vec<usize> = entries
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, entry)| !peeked.contains(&entry.block_string()))
|
|
.map(|(idx, _)| idx)
|
|
.collect();
|
|
if matches!(mode, ListMode::Bottom) {
|
|
indices.reverse();
|
|
}
|
|
indices
|
|
}
|
|
|
|
fn ordered_indices(entries: &[EntryBlock], mode: ListMode) -> Vec<usize> {
|
|
let mut indices: Vec<usize> = (0..entries.len()).collect();
|
|
if matches!(mode, ListMode::Bottom) {
|
|
indices.reverse();
|
|
}
|
|
indices
|
|
}
|
|
|
|
fn peek_indices(
|
|
entries: &[EntryBlock],
|
|
peeked: &HashSet<String>,
|
|
mode: ListMode,
|
|
page: usize,
|
|
) -> Vec<usize> {
|
|
let ordered = ordered_unpeeked_indices(entries, peeked, mode);
|
|
if ordered.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let start = page * PAGE_SIZE;
|
|
if start >= ordered.len() {
|
|
return Vec::new();
|
|
}
|
|
let end = (start + PAGE_SIZE).min(ordered.len());
|
|
ordered[start..end].to_vec()
|
|
}
|
|
|
|
fn peek_indices_all(entries: &[EntryBlock], mode: ListMode, page: usize) -> Vec<usize> {
|
|
let ordered = ordered_indices(entries, mode);
|
|
if ordered.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let start = page * PAGE_SIZE;
|
|
if start >= ordered.len() {
|
|
return Vec::new();
|
|
}
|
|
let end = (start + PAGE_SIZE).min(ordered.len());
|
|
ordered[start..end].to_vec()
|
|
}
|
|
|
|
fn peek_indices_for_session(
|
|
session: &ListSession,
|
|
peeked: &HashSet<String>,
|
|
mode: ListMode,
|
|
page: usize,
|
|
) -> Vec<usize> {
|
|
match session.kind {
|
|
SessionKind::Search { .. } => peek_indices_all(&session.entries, mode, page),
|
|
SessionKind::List => peek_indices(&session.entries, peeked, mode, page),
|
|
}
|
|
}
|
|
|
|
fn normalize_peek_view(session: &mut ListSession, peeked: &HashSet<String>) {
|
|
if let ListView::Peek { mode, page } = session.view.clone() {
|
|
let indices = peek_indices_for_session(session, peeked, mode, page);
|
|
if indices.is_empty() && page > 0 {
|
|
session.view = ListView::Peek {
|
|
mode,
|
|
page: page.saturating_sub(1),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
fn preview_text(text: &str) -> Vec<String> {
|
|
let normalized = normalize_line_endings(text);
|
|
let lines: Vec<&str> = normalized.lines().collect();
|
|
let mut out = Vec::new();
|
|
if let Some(first) = lines.get(0) {
|
|
out.push(first.to_string());
|
|
}
|
|
if let Some(second) = lines.get(1) {
|
|
out.push(second.to_string());
|
|
}
|
|
if lines.len() > 2 {
|
|
if let Some(last) = out.last_mut() {
|
|
last.push_str("...");
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn undo_preview(entry: &str) -> Vec<String> {
|
|
let entry = EntryBlock::from_block(entry);
|
|
entry.preview_lines()
|
|
}
|
|
|
|
async fn send_ephemeral(
|
|
bot: &Bot,
|
|
chat_id: ChatId,
|
|
text: &str,
|
|
ttl_secs: u64,
|
|
) -> Result<()> {
|
|
let sent = bot.send_message(chat_id, text).await?;
|
|
let bot = bot.clone();
|
|
tokio::spawn(async move {
|
|
tokio::time::sleep(Duration::from_secs(ttl_secs)).await;
|
|
let _ = bot.delete_message(chat_id, sent.id).await;
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_error(bot: &Bot, chat_id: ChatId, text: &str) -> Result<()> {
|
|
bot.send_message(chat_id, text).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_embedded_media_for_view(
|
|
bot: &Bot,
|
|
chat_id: ChatId,
|
|
state: &std::sync::Arc<AppState>,
|
|
session: &ListSession,
|
|
peeked: &HashSet<String>,
|
|
) -> Result<()> {
|
|
let lines = embedded_lines_for_view(session, peeked);
|
|
let embeds = extract_embedded_paths(&lines, &state.config);
|
|
for path in embeds {
|
|
if is_image_path(&path) {
|
|
bot.send_photo(chat_id, InputFile::file(path)).await?;
|
|
} else {
|
|
bot.send_document(chat_id, InputFile::file(path)).await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn reset_peeked(state: &std::sync::Arc<AppState>) {
|
|
let mut peeked = state.peeked.lock().await;
|
|
peeked.clear();
|
|
}
|
|
|
|
async fn add_undo(
|
|
state: &std::sync::Arc<AppState>,
|
|
kind: UndoKind,
|
|
entry: String,
|
|
) -> Result<String> {
|
|
let mut undo = state.undo.lock().await;
|
|
prune_undo(&mut undo);
|
|
let id = short_id();
|
|
undo.push(UndoRecord {
|
|
id: id.clone(),
|
|
kind,
|
|
entry,
|
|
expires_at: now_ts() + UNDO_TTL_SECS,
|
|
});
|
|
save_undo(&state.undo_path, &undo)?;
|
|
Ok(id)
|
|
}
|
|
|
|
async fn with_retries<F, T>(mut f: F) -> Result<T>
|
|
where
|
|
F: FnMut() -> Result<T>,
|
|
{
|
|
let mut last_err = None;
|
|
for attempt in 0..3 {
|
|
match f() {
|
|
Ok(value) => return Ok(value),
|
|
Err(err) => last_err = Some(err),
|
|
}
|
|
if attempt < 2 {
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
}
|
|
}
|
|
Err(last_err.unwrap_or_else(|| anyhow!("retry failed")))
|
|
}
|
|
|
|
fn resolve_user_id(input: UserIdInput, config_dir: &Path) -> Result<u64> {
|
|
match input {
|
|
UserIdInput::Number(value) => Ok(value),
|
|
UserIdInput::String(raw) => resolve_user_id_string(&raw, config_dir),
|
|
UserIdInput::File { file } => {
|
|
let path = resolve_user_id_path(&file, config_dir);
|
|
read_user_id_file(&path)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn resolve_user_id_string(raw: &str, config_dir: &Path) -> Result<u64> {
|
|
let trimmed = raw.trim();
|
|
if trimmed.is_empty() {
|
|
return Err(anyhow!("user_id is empty"));
|
|
}
|
|
if trimmed.chars().all(|c| c.is_ascii_digit()) {
|
|
return parse_user_id_value(trimmed).context("parse user_id");
|
|
}
|
|
let path = resolve_user_id_path(Path::new(trimmed), config_dir);
|
|
read_user_id_file(&path)
|
|
}
|
|
|
|
fn resolve_user_id_path(path: &Path, config_dir: &Path) -> PathBuf {
|
|
if path.is_relative() {
|
|
config_dir.join(path)
|
|
} else {
|
|
path.to_path_buf()
|
|
}
|
|
}
|
|
|
|
fn read_user_id_file(path: &Path) -> Result<u64> {
|
|
let contents =
|
|
fs::read_to_string(path).with_context(|| format!("read user_id file {}", path.display()))?;
|
|
parse_user_id_value(contents.trim())
|
|
.with_context(|| format!("parse user_id from {}", path.display()))
|
|
}
|
|
|
|
fn parse_user_id_value(raw: &str) -> Result<u64> {
|
|
let trimmed = raw.trim();
|
|
if trimmed.is_empty() {
|
|
return Err(anyhow!("user_id is empty"));
|
|
}
|
|
trimmed.parse::<u64>().context("parse user_id")
|
|
}
|
|
|
|
fn load_config(path: &Path) -> Result<Config> {
|
|
let contents = fs::read_to_string(path).with_context(|| format!("read config {}", path.display()))?;
|
|
let config_file: ConfigFile = toml::from_str(&contents).context("parse config")?;
|
|
let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
|
let user_id = resolve_user_id(config_file.user_id, config_dir)?;
|
|
let default_media_dir = config_file
|
|
.read_later_path
|
|
.parent()
|
|
.unwrap_or_else(|| Path::new("."))
|
|
.join("Misc/images_misc");
|
|
let media_dir = config_file.media_dir.unwrap_or(default_media_dir);
|
|
Ok(Config {
|
|
token: config_file.token,
|
|
user_id,
|
|
read_later_path: config_file.read_later_path,
|
|
finished_path: config_file.finished_path,
|
|
resources_path: config_file.resources_path,
|
|
media_dir,
|
|
data_dir: config_file.data_dir,
|
|
retry_interval_seconds: config_file.retry_interval_seconds,
|
|
sync: config_file.sync,
|
|
})
|
|
}
|
|
|
|
fn list_resource_files(dir: &Path) -> Result<Vec<PathBuf>> {
|
|
let mut files = Vec::new();
|
|
if !dir.exists() {
|
|
return Ok(files);
|
|
}
|
|
let entries = fs::read_dir(dir).with_context(|| format!("read dir {}", dir.display()))?;
|
|
for entry in entries {
|
|
let entry = entry.with_context(|| format!("read dir entry {}", dir.display()))?;
|
|
let path = entry.path();
|
|
let file_type = entry
|
|
.file_type()
|
|
.with_context(|| format!("read file type {}", path.display()))?;
|
|
if !file_type.is_file() {
|
|
continue;
|
|
}
|
|
let is_md = path
|
|
.extension()
|
|
.and_then(|ext| ext.to_str())
|
|
.map(|ext| ext.eq_ignore_ascii_case("md"))
|
|
.unwrap_or(false);
|
|
if is_md {
|
|
files.push(path);
|
|
}
|
|
}
|
|
files.sort_by(|a, b| {
|
|
let a_name = a.file_name().map(|n| n.to_string_lossy()).unwrap_or_default();
|
|
let b_name = b.file_name().map(|n| n.to_string_lossy()).unwrap_or_default();
|
|
a_name.cmp(&b_name)
|
|
});
|
|
Ok(files)
|
|
}
|
|
|
|
fn read_entries(path: &Path) -> Result<(Vec<String>, Vec<EntryBlock>)> {
|
|
if !path.exists() {
|
|
return Ok((Vec::new(), Vec::new()));
|
|
}
|
|
let contents = fs::read_to_string(path)
|
|
.with_context(|| format!("read file {}", path.display()))?;
|
|
let normalized = normalize_line_endings(&contents);
|
|
Ok(parse_entries(&normalized))
|
|
}
|
|
|
|
fn parse_entries(contents: &str) -> (Vec<String>, Vec<EntryBlock>) {
|
|
let mut preamble = Vec::new();
|
|
let mut entries: Vec<EntryBlock> = Vec::new();
|
|
let mut current: Vec<String> = Vec::new();
|
|
let mut in_entries = false;
|
|
|
|
for line in contents.lines() {
|
|
if line.starts_with('-') {
|
|
if in_entries && !current.is_empty() {
|
|
entries.push(EntryBlock { lines: current });
|
|
current = Vec::new();
|
|
}
|
|
in_entries = true;
|
|
current.push(line.to_string());
|
|
} else if in_entries {
|
|
current.push(line.to_string());
|
|
} else {
|
|
preamble.push(line.to_string());
|
|
}
|
|
}
|
|
|
|
if in_entries && !current.is_empty() {
|
|
entries.push(EntryBlock { lines: current });
|
|
}
|
|
|
|
(preamble, entries)
|
|
}
|
|
|
|
fn write_entries(path: &Path, preamble: &[String], entries: &[EntryBlock]) -> Result<()> {
|
|
let mut lines: Vec<String> = Vec::new();
|
|
lines.extend_from_slice(preamble);
|
|
for entry in entries {
|
|
lines.extend(entry.lines.clone());
|
|
}
|
|
let mut content = lines.join("\n");
|
|
if !content.is_empty() {
|
|
content.push('\n');
|
|
}
|
|
atomic_write(path, content.as_bytes())
|
|
}
|
|
|
|
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
|
let dir = path
|
|
.parent()
|
|
.ok_or_else(|| anyhow!("no parent dir for {}", path.display()))?;
|
|
fs::create_dir_all(dir).with_context(|| format!("create dir {}", dir.display()))?;
|
|
let mut tmp = tempfile::NamedTempFile::new_in(dir)
|
|
.with_context(|| format!("create temp file in {}", dir.display()))?;
|
|
tmp.write_all(data).context("write temp file")?;
|
|
tmp.flush().context("flush temp file")?;
|
|
tmp.as_file_mut().sync_all().context("sync temp file")?;
|
|
tmp.persist(path)
|
|
.map_err(|e| anyhow!("persist temp file: {}", e))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn add_entry_sync(path: &Path, entry: &EntryBlock) -> Result<AddOutcome> {
|
|
let (preamble, mut entries) = read_entries(path)?;
|
|
let block = entry.block_string();
|
|
if entries.iter().any(|e| e.block_string() == block) {
|
|
return Ok(AddOutcome::Duplicate);
|
|
}
|
|
entries.insert(0, entry.clone());
|
|
write_entries(path, &preamble, &entries)?;
|
|
Ok(AddOutcome::Added)
|
|
}
|
|
|
|
fn add_resource_entry_sync(path: &Path, entry_block: &str) -> Result<AddOutcome> {
|
|
let existing = if path.exists() {
|
|
fs::read_to_string(path).with_context(|| format!("read file {}", path.display()))?
|
|
} else {
|
|
String::new()
|
|
};
|
|
let normalized = normalize_line_endings(&existing);
|
|
let (_, entries) = parse_entries(&normalized);
|
|
if entries.iter().any(|e| e.block_string() == entry_block) {
|
|
return Ok(AddOutcome::Duplicate);
|
|
}
|
|
|
|
let mut preserved = normalized;
|
|
if !preserved.is_empty() && !preserved.ends_with('\n') {
|
|
preserved.push('\n');
|
|
}
|
|
|
|
let mut content = String::new();
|
|
content.push_str(entry_block);
|
|
content.push('\n');
|
|
content.push_str(&preserved);
|
|
if !content.ends_with('\n') {
|
|
content.push('\n');
|
|
}
|
|
atomic_write(path, content.as_bytes())?;
|
|
Ok(AddOutcome::Added)
|
|
}
|
|
|
|
fn delete_entry_sync(path: &Path, entry_block: &str) -> Result<ModifyOutcome> {
|
|
let (preamble, mut entries) = read_entries(path)?;
|
|
let pos = entries
|
|
.iter()
|
|
.position(|e| e.block_string() == entry_block);
|
|
let Some(pos) = pos else {
|
|
return Ok(ModifyOutcome::NotFound);
|
|
};
|
|
entries.remove(pos);
|
|
write_entries(path, &preamble, &entries)?;
|
|
Ok(ModifyOutcome::Applied)
|
|
}
|
|
|
|
fn update_entry_sync(
|
|
path: &Path,
|
|
entry_block: &str,
|
|
updated_entry: &EntryBlock,
|
|
) -> Result<ModifyOutcome> {
|
|
let (preamble, mut entries) = read_entries(path)?;
|
|
let pos = entries
|
|
.iter()
|
|
.position(|e| e.block_string() == entry_block);
|
|
let Some(pos) = pos else {
|
|
return Ok(ModifyOutcome::NotFound);
|
|
};
|
|
entries[pos] = updated_entry.clone();
|
|
write_entries(path, &preamble, &entries)?;
|
|
Ok(ModifyOutcome::Applied)
|
|
}
|
|
|
|
fn move_to_finished_sync(
|
|
read_later: &Path,
|
|
finished: &Path,
|
|
entry_block: &str,
|
|
) -> Result<ModifyOutcome> {
|
|
let (preamble_rl, mut entries_rl) = read_entries(read_later)?;
|
|
let pos = entries_rl
|
|
.iter()
|
|
.position(|e| e.block_string() == entry_block);
|
|
let Some(pos) = pos else {
|
|
return Ok(ModifyOutcome::NotFound);
|
|
};
|
|
let entry = entries_rl.remove(pos);
|
|
|
|
let (preamble_fin, mut entries_fin) = read_entries(finished)?;
|
|
entries_fin.insert(0, entry);
|
|
write_entries(finished, &preamble_fin, &entries_fin)?;
|
|
write_entries(read_later, &preamble_rl, &entries_rl)?;
|
|
Ok(ModifyOutcome::Applied)
|
|
}
|
|
|
|
fn move_to_finished_updated_sync(
|
|
read_later: &Path,
|
|
finished: &Path,
|
|
entry_block: &str,
|
|
updated_entry: &str,
|
|
) -> Result<ModifyOutcome> {
|
|
let (preamble_rl, mut entries_rl) = read_entries(read_later)?;
|
|
let pos = entries_rl
|
|
.iter()
|
|
.position(|e| e.block_string() == entry_block);
|
|
let Some(pos) = pos else {
|
|
return Ok(ModifyOutcome::NotFound);
|
|
};
|
|
entries_rl.remove(pos);
|
|
|
|
let (preamble_fin, mut entries_fin) = read_entries(finished)?;
|
|
let updated_entry = EntryBlock::from_block(updated_entry);
|
|
entries_fin.insert(0, updated_entry);
|
|
write_entries(finished, &preamble_fin, &entries_fin)?;
|
|
write_entries(read_later, &preamble_rl, &entries_rl)?;
|
|
Ok(ModifyOutcome::Applied)
|
|
}
|
|
|
|
fn move_to_read_later_sync(
|
|
read_later: &Path,
|
|
finished: &Path,
|
|
entry_block: &str,
|
|
) -> Result<ModifyOutcome> {
|
|
let (preamble_fin, mut entries_fin) = read_entries(finished)?;
|
|
let pos = entries_fin
|
|
.iter()
|
|
.position(|e| e.block_string() == entry_block);
|
|
let Some(pos) = pos else {
|
|
return Ok(ModifyOutcome::NotFound);
|
|
};
|
|
let entry = entries_fin.remove(pos);
|
|
|
|
let (preamble_rl, mut entries_rl) = read_entries(read_later)?;
|
|
entries_rl.insert(0, entry);
|
|
write_entries(read_later, &preamble_rl, &entries_rl)?;
|
|
write_entries(finished, &preamble_fin, &entries_fin)?;
|
|
Ok(ModifyOutcome::Applied)
|
|
}
|
|
|
|
fn load_queue(path: &Path) -> Result<Vec<QueuedOp>> {
|
|
if !path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let data = fs::read_to_string(path).with_context(|| format!("read queue {}", path.display()))?;
|
|
let queue = serde_json::from_str(&data).context("parse queue")?;
|
|
Ok(queue)
|
|
}
|
|
|
|
fn save_queue(path: &Path, queue: &[QueuedOp]) -> Result<()> {
|
|
let data = serde_json::to_vec_pretty(queue).context("serialize queue")?;
|
|
atomic_write(path, &data)
|
|
}
|
|
|
|
fn load_undo(path: &Path) -> Result<Vec<UndoRecord>> {
|
|
if !path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let data = fs::read_to_string(path).with_context(|| format!("read undo {}", path.display()))?;
|
|
let undo = serde_json::from_str(&data).context("parse undo")?;
|
|
Ok(undo)
|
|
}
|
|
|
|
fn save_undo(path: &Path, undo: &[UndoRecord]) -> Result<()> {
|
|
let data = serde_json::to_vec_pretty(undo).context("serialize undo")?;
|
|
atomic_write(path, &data)
|
|
}
|
|
|
|
fn prune_undo(undo: &mut Vec<UndoRecord>) {
|
|
let now = now_ts();
|
|
undo.retain(|r| r.expires_at > now);
|
|
}
|
|
|
|
fn normalize_line_endings(input: &str) -> String {
|
|
input.replace("\r\n", "\n").replace('\r', "\n")
|
|
}
|
|
|
|
fn resource_block_from_text(text: &str) -> String {
|
|
let normalized = normalize_line_endings(text);
|
|
let mut lines: Vec<String> = normalized.lines().map(|s| s.to_string()).collect();
|
|
if lines.is_empty() {
|
|
lines.push(String::new());
|
|
}
|
|
if let Some(first) = lines.get_mut(0) {
|
|
*first = format!("- (Auto-Resource): {}", first);
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
|
|
fn sanitize_resource_filename(input: &str) -> Result<String> {
|
|
let trimmed = input.trim();
|
|
let first_line = trimmed.lines().next().unwrap_or("").trim();
|
|
if first_line.is_empty() {
|
|
return Err(anyhow!("Provide a filename."));
|
|
}
|
|
if first_line == "." || first_line == ".." {
|
|
return Err(anyhow!("Invalid filename."));
|
|
}
|
|
if first_line.contains('/') || first_line.contains('\\') {
|
|
return Err(anyhow!("Invalid filename."));
|
|
}
|
|
let mut name = first_line.to_string();
|
|
if !name.to_lowercase().ends_with(".md") {
|
|
name.push_str(".md");
|
|
}
|
|
Ok(name)
|
|
}
|
|
|
|
fn sanitize_filename_with_default(input: &str, default_ext: Option<&str>) -> String {
|
|
let mut sanitized: String = input
|
|
.chars()
|
|
.map(|c| {
|
|
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
|
|
c
|
|
} else {
|
|
'_'
|
|
}
|
|
})
|
|
.collect();
|
|
if sanitized.is_empty() {
|
|
sanitized = "file".to_string();
|
|
}
|
|
if Path::new(&sanitized).extension().is_none() {
|
|
if let Some(ext) = default_ext {
|
|
sanitized.push('.');
|
|
sanitized.push_str(ext);
|
|
}
|
|
}
|
|
sanitized
|
|
}
|
|
|
|
fn extension_from_mime(mime: &str) -> Option<&str> {
|
|
let (_, subtype) = mime.split_once('/')?;
|
|
if subtype.eq_ignore_ascii_case("jpeg") {
|
|
Some("jpg")
|
|
} else {
|
|
Some(subtype)
|
|
}
|
|
}
|
|
|
|
fn build_media_entry_text(filename: &str, caption: Option<&str>) -> String {
|
|
let mut text = format!("![[{}]]", filename);
|
|
if let Some(caption) = caption {
|
|
let normalized = normalize_line_endings(caption).trim().to_string();
|
|
if !normalized.is_empty() {
|
|
text.push('\n');
|
|
text.push_str(&normalized);
|
|
}
|
|
}
|
|
text
|
|
}
|
|
|
|
fn format_embedded_references_for_lines(lines: &[String], config: &Config) -> Vec<String> {
|
|
let mut labels: HashMap<PathBuf, usize> = HashMap::new();
|
|
let mut next_label = 1usize;
|
|
let mut output = Vec::with_capacity(lines.len());
|
|
|
|
for line in lines {
|
|
let mut formatted = String::with_capacity(line.len());
|
|
let mut index = 0;
|
|
while let Some(start_rel) = line[index..].find("![[") {
|
|
let marker_start = index + start_rel;
|
|
formatted.push_str(&line[index..marker_start]);
|
|
|
|
let marker_content_start = marker_start + 3;
|
|
let Some(end_rel) = line[marker_content_start..].find("]]") else {
|
|
formatted.push_str(&line[marker_start..]);
|
|
index = line.len();
|
|
break;
|
|
};
|
|
let marker_content_end = marker_content_start + end_rel;
|
|
let marker_end = marker_content_end + 2;
|
|
let marker_inner = &line[marker_content_start..marker_content_end];
|
|
|
|
if let Some(path) = resolve_embedded_path(marker_inner, config) {
|
|
let label = match labels.get(&path) {
|
|
Some(label) => *label,
|
|
None => {
|
|
let assigned = next_label;
|
|
labels.insert(path.clone(), assigned);
|
|
next_label += 1;
|
|
assigned
|
|
}
|
|
};
|
|
if is_image_path(&path) {
|
|
formatted.push_str(&format!("image #{}", label));
|
|
} else {
|
|
formatted.push_str(&format!("file #{}", label));
|
|
}
|
|
} else {
|
|
formatted.push_str(&line[marker_start..marker_end]);
|
|
}
|
|
|
|
index = marker_end;
|
|
}
|
|
formatted.push_str(&line[index..]);
|
|
output.push(formatted);
|
|
}
|
|
|
|
output
|
|
}
|
|
|
|
fn pick_best_photo(photos: &[teloxide::types::PhotoSize]) -> Option<&teloxide::types::PhotoSize> {
|
|
photos.iter().max_by_key(|photo| {
|
|
photo.file.size.max((photo.width * photo.height) as u32) as u64
|
|
})
|
|
}
|
|
|
|
async fn download_telegram_file(bot: &Bot, file_id: &str, dest_path: &Path) -> Result<()> {
|
|
let file = bot.get_file(file_id).await?;
|
|
let mut out = tokio::fs::File::create(dest_path).await?;
|
|
bot.download_file(&file.path, &mut out).await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn extract_embedded_paths(lines: &[String], config: &Config) -> Vec<PathBuf> {
|
|
let mut paths = Vec::new();
|
|
let mut seen = HashSet::new();
|
|
for line in lines {
|
|
let mut index = 0;
|
|
while let Some(start_rel) = line[index..].find("![[") {
|
|
let start = index + start_rel + 3;
|
|
let Some(end_rel) = line[start..].find("]]") else {
|
|
break;
|
|
};
|
|
let end = start + end_rel;
|
|
let inner = &line[start..end];
|
|
if let Some(path) = resolve_embedded_path(inner, config) {
|
|
if seen.insert(path.clone()) {
|
|
paths.push(path);
|
|
}
|
|
}
|
|
index = end + 2;
|
|
}
|
|
}
|
|
paths
|
|
}
|
|
|
|
fn resolve_embedded_path(inner: &str, config: &Config) -> Option<PathBuf> {
|
|
let mut inner = inner.trim();
|
|
if let Some((path_part, _)) = inner.split_once('|') {
|
|
inner = path_part.trim();
|
|
}
|
|
if inner.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let vault_root = config
|
|
.read_later_path
|
|
.parent()
|
|
.unwrap_or_else(|| Path::new("."));
|
|
let path = if Path::new(inner).is_absolute() {
|
|
PathBuf::from(inner)
|
|
} else if inner.contains('/') || inner.contains('\\') {
|
|
vault_root.join(inner)
|
|
} else {
|
|
config.media_dir.join(inner)
|
|
};
|
|
|
|
if path.exists() {
|
|
Some(path)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn is_image_path(path: &Path) -> bool {
|
|
match path.extension().and_then(|ext| ext.to_str()) {
|
|
Some(ext) => matches!(
|
|
ext.to_ascii_lowercase().as_str(),
|
|
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
|
|
),
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
fn parse_command(text: &str) -> Option<&str> {
|
|
let first = text.split_whitespace().next()?;
|
|
if !first.starts_with('/') {
|
|
return None;
|
|
}
|
|
let cmd = first.trim_start_matches('/');
|
|
Some(cmd.split('@').next().unwrap_or(cmd))
|
|
}
|
|
|
|
fn short_id() -> String {
|
|
let id = Uuid::new_v4().to_string();
|
|
id.split('-').next().unwrap_or(&id).to_string()
|
|
}
|
|
|
|
fn now_ts() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_else(|_| Duration::from_secs(0))
|
|
.as_secs()
|
|
}
|
|
|
|
fn chat_id_from_user_id(user_id: u64) -> ChatId {
|
|
ChatId(user_id as i64)
|
|
}
|
|
|
|
fn start_retry_loop(state: std::sync::Arc<AppState>, interval_secs: u64) {
|
|
tokio::spawn(async move {
|
|
let mut interval = tokio::time::interval(Duration::from_secs(interval_secs));
|
|
loop {
|
|
interval.tick().await;
|
|
if let Err(err) = process_queue(state.clone()).await {
|
|
error!("queue processing failed: {:#}", err);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn process_queue(state: std::sync::Arc<AppState>) -> Result<()> {
|
|
let pending = {
|
|
let mut queue = state.queue.lock().await;
|
|
std::mem::take(&mut *queue)
|
|
};
|
|
|
|
if pending.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut remaining = Vec::new();
|
|
for op in pending {
|
|
match apply_op(&state, &op).await {
|
|
Ok(_) => {}
|
|
Err(err) => {
|
|
error!("queued op failed: {:#}", err);
|
|
remaining.push(op);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut queue = state.queue.lock().await;
|
|
if !queue.is_empty() {
|
|
remaining.extend(queue.drain(..));
|
|
}
|
|
*queue = remaining;
|
|
save_queue(&state.queue_path, &queue)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::collections::HashSet;
|
|
use std::os::unix::process::ExitStatusExt;
|
|
|
|
fn entry(text: &str) -> EntryBlock {
|
|
EntryBlock::from_text(text)
|
|
}
|
|
|
|
fn test_config() -> Config {
|
|
Config {
|
|
token: "token".to_string(),
|
|
user_id: 1,
|
|
read_later_path: PathBuf::from("/tmp/read-later.md"),
|
|
finished_path: PathBuf::from("/tmp/finished.md"),
|
|
resources_path: PathBuf::from("/tmp/resources"),
|
|
media_dir: PathBuf::from("/tmp/media"),
|
|
data_dir: PathBuf::from("/tmp/data"),
|
|
retry_interval_seconds: None,
|
|
sync: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_markdown_links_replaces_single_link() {
|
|
let input = "See [post](https://example.com/post) now";
|
|
let (out, changed) = normalize_markdown_links(input);
|
|
assert!(changed);
|
|
assert_eq!(out, "See https://example.com/post now");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_markdown_links_replaces_multiple_links() {
|
|
let input = "[a](one) and [b](two)";
|
|
let (out, changed) = normalize_markdown_links(input);
|
|
assert!(changed);
|
|
assert_eq!(out, "one and two");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_markdown_links_ignores_invalid_markup() {
|
|
let input = "broken [link](missing";
|
|
let (out, changed) = normalize_markdown_links(input);
|
|
assert!(!changed);
|
|
assert_eq!(out, input);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_entry_markdown_links_updates_entry() {
|
|
let entry = EntryBlock::from_text("foo [x](url)\nbar");
|
|
let normalized = normalize_entry_markdown_links(&entry).unwrap();
|
|
let block = normalized.block_string();
|
|
assert!(block.contains("foo url"));
|
|
assert!(!block.contains("[x]"));
|
|
}
|
|
|
|
#[test]
|
|
fn peek_indices_filters_and_pages() {
|
|
let entries: Vec<EntryBlock> = (0..6)
|
|
.map(|i| entry(&format!("item {}", i)))
|
|
.collect();
|
|
let mut peeked = HashSet::new();
|
|
peeked.insert(entries[1].block_string());
|
|
peeked.insert(entries[3].block_string());
|
|
|
|
assert_eq!(count_unpeeked_entries(&entries, &peeked), 4);
|
|
assert_eq!(
|
|
peek_indices(&entries, &peeked, ListMode::Top, 0),
|
|
vec![0, 2, 4]
|
|
);
|
|
assert_eq!(
|
|
peek_indices(&entries, &peeked, ListMode::Top, 1),
|
|
vec![5]
|
|
);
|
|
assert_eq!(
|
|
peek_indices(&entries, &peeked, ListMode::Bottom, 0),
|
|
vec![5, 4, 2]
|
|
);
|
|
assert_eq!(
|
|
peek_indices(&entries, &peeked, ListMode::Bottom, 1),
|
|
vec![0]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_peek_indices_ignore_peeked_entries() {
|
|
let entries: Vec<EntryBlock> = (0..4)
|
|
.map(|i| entry(&format!("match {}", i)))
|
|
.collect();
|
|
let session = ListSession {
|
|
id: "session".to_string(),
|
|
chat_id: 0,
|
|
kind: SessionKind::Search {
|
|
query: "match".to_string(),
|
|
},
|
|
entries: entries.clone(),
|
|
view: ListView::Peek {
|
|
mode: ListMode::Top,
|
|
page: 0,
|
|
},
|
|
seen_random: HashSet::new(),
|
|
message_id: None,
|
|
};
|
|
let mut peeked = HashSet::new();
|
|
for entry in &entries {
|
|
peeked.insert(entry.block_string());
|
|
}
|
|
|
|
assert_eq!(count_visible_entries(&session, &peeked), 4);
|
|
assert_eq!(
|
|
peek_indices_for_session(&session, &peeked, ListMode::Top, 0),
|
|
vec![0, 1, 2]
|
|
);
|
|
assert_eq!(
|
|
peek_indices_for_session(&session, &peeked, ListMode::Top, 1),
|
|
vec![3]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_peek_view_shows_all_peeked_message() {
|
|
let entries = vec![entry("one"), entry("two")];
|
|
let session = ListSession {
|
|
id: "session".to_string(),
|
|
chat_id: 0,
|
|
kind: SessionKind::List,
|
|
entries: entries.clone(),
|
|
view: ListView::Peek {
|
|
mode: ListMode::Top,
|
|
page: 0,
|
|
},
|
|
seen_random: HashSet::new(),
|
|
message_id: None,
|
|
};
|
|
let mut peeked = HashSet::new();
|
|
for entry in &entries {
|
|
peeked.insert(entry.block_string());
|
|
}
|
|
let config = test_config();
|
|
let (text, _kb) = build_peek_view("session", &session, ListMode::Top, 0, &peeked, &config);
|
|
assert!(text.contains("Everything's been peeked already."));
|
|
}
|
|
|
|
#[test]
|
|
fn format_embedded_references_labels_images_and_files() {
|
|
let temp = TempDir::new().unwrap();
|
|
let media_dir = temp.path().join("media");
|
|
fs::create_dir_all(&media_dir).unwrap();
|
|
fs::write(media_dir.join("image-1.jpg"), b"x").unwrap();
|
|
fs::write(media_dir.join("doc-1.pdf"), b"x").unwrap();
|
|
|
|
let mut config = test_config();
|
|
config.media_dir = media_dir;
|
|
|
|
let lines = vec![
|
|
"![[image-1.jpg]] and ![[doc-1.pdf]]".to_string(),
|
|
"repeat ![[image-1.jpg]]".to_string(),
|
|
];
|
|
let rendered = format_embedded_references_for_lines(&lines, &config);
|
|
|
|
assert_eq!(rendered[0], "image #1 and file #2");
|
|
assert_eq!(rendered[1], "repeat image #1");
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_lines_for_peek_use_preview_only() {
|
|
let entry = EntryBlock::from_text("first line\nsecond line\n![[image-2.jpg]]");
|
|
let session = ListSession {
|
|
id: "session".to_string(),
|
|
chat_id: 0,
|
|
kind: SessionKind::List,
|
|
entries: vec![entry],
|
|
view: ListView::Peek {
|
|
mode: ListMode::Top,
|
|
page: 0,
|
|
},
|
|
seen_random: HashSet::new(),
|
|
message_id: None,
|
|
};
|
|
|
|
let lines = embedded_lines_for_view(&session, &HashSet::new());
|
|
assert_eq!(lines, vec!["first line".to_string(), "second line...".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn build_undos_view_includes_labels_and_previews() {
|
|
let record_one = UndoRecord {
|
|
id: "one".to_string(),
|
|
kind: UndoKind::Delete,
|
|
entry: entry("alpha").block_string(),
|
|
expires_at: now_ts() + 10,
|
|
};
|
|
let record_two = UndoRecord {
|
|
id: "two".to_string(),
|
|
kind: UndoKind::MoveToFinished,
|
|
entry: entry("beta").block_string(),
|
|
expires_at: now_ts() + 10,
|
|
};
|
|
let (text, _kb) = build_undos_view("session", &[record_one, record_two]);
|
|
assert!(text.contains("Undos (2)"));
|
|
assert!(text.contains("1) Deleted"));
|
|
assert!(text.contains("2) Moved to finished"));
|
|
assert!(text.contains("alpha"));
|
|
assert!(text.contains("beta"));
|
|
}
|
|
|
|
#[test]
|
|
fn displayed_indices_for_selected_view() {
|
|
let entries = vec![entry("one"), entry("two"), entry("three")];
|
|
let session = ListSession {
|
|
id: "session".to_string(),
|
|
chat_id: 0,
|
|
kind: SessionKind::List,
|
|
entries,
|
|
view: ListView::Selected {
|
|
return_to: Box::new(ListView::Menu),
|
|
index: 1,
|
|
},
|
|
seen_random: HashSet::new(),
|
|
message_id: None,
|
|
};
|
|
let peeked = HashSet::new();
|
|
assert_eq!(displayed_indices_for_view(&session, &peeked), vec![1]);
|
|
}
|
|
|
|
#[test]
|
|
fn norm_target_index_prefers_single_peek_item() {
|
|
let entries = vec![entry("one"), entry("two")];
|
|
let mut peeked = HashSet::new();
|
|
peeked.insert(entries[0].block_string());
|
|
let session = ListSession {
|
|
id: "session".to_string(),
|
|
chat_id: 0,
|
|
kind: SessionKind::List,
|
|
entries: entries.clone(),
|
|
view: ListView::Peek {
|
|
mode: ListMode::Top,
|
|
page: 0,
|
|
},
|
|
seen_random: HashSet::new(),
|
|
message_id: None,
|
|
};
|
|
assert_eq!(norm_target_index(&session, &peeked), Some(1));
|
|
|
|
let session_multi = ListSession {
|
|
entries,
|
|
..session
|
|
};
|
|
let empty_peeked = HashSet::new();
|
|
assert_eq!(norm_target_index(&session_multi, &empty_peeked), None);
|
|
}
|
|
|
|
#[test]
|
|
fn command_keywords_are_case_insensitive() {
|
|
assert!(is_norm_message("NoRm"));
|
|
assert!(is_instant_delete_message("DEL"));
|
|
assert!(is_instant_delete_message("Delete"));
|
|
assert!(!is_instant_delete_message("remove"));
|
|
}
|
|
|
|
#[test]
|
|
fn extract_https_username_from_remote() {
|
|
assert_eq!(
|
|
extract_https_username("https://user@host/repo.git"),
|
|
Some("user".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_https_username("https://user:pass@host/repo.git"),
|
|
Some("user".to_string())
|
|
);
|
|
assert_eq!(extract_https_username("https://host/repo.git"), None);
|
|
assert_eq!(extract_https_username("git@host:repo.git"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn read_token_file_trims_whitespace() {
|
|
let mut file = NamedTempFile::new().unwrap();
|
|
file.write_all(b" token\n").unwrap();
|
|
let token = read_token_file(file.path()).unwrap();
|
|
assert_eq!(token, "token");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_pull_mode_accepts_theirs() {
|
|
assert!(matches!(parse_pull_mode(""), Ok(PullMode::FastForward)));
|
|
assert!(matches!(
|
|
parse_pull_mode("theirs"),
|
|
Ok(PullMode::Theirs)
|
|
));
|
|
assert!(parse_pull_mode("unknown").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn is_already_up_to_date_detects_output() {
|
|
let output = GitOutput {
|
|
status: std::process::ExitStatus::from_raw(0),
|
|
stdout: "Already up to date.".to_string(),
|
|
stderr: String::new(),
|
|
};
|
|
assert!(is_already_up_to_date(&output));
|
|
}
|
|
|
|
#[test]
|
|
fn is_push_up_to_date_detects_output() {
|
|
let output = GitOutput {
|
|
status: std::process::ExitStatus::from_raw(0),
|
|
stdout: "Everything up-to-date".to_string(),
|
|
stderr: String::new(),
|
|
};
|
|
assert!(is_push_up_to_date(&output));
|
|
}
|
|
}
|