Update quick command flow and reset scopes
Make /top, /bottom(/last), and /random use a separate single-item quick session with independent seen tracking and next navigation. Add /reset scope selection (all/list/quick) and expand AGENTS.md with build, test, lint, and style guidance for coding agents.
This commit is contained in:
parent
35b234c897
commit
e1afcc5148
6 changed files with 403 additions and 33 deletions
173
AGENTS.md
173
AGENTS.md
|
|
@ -1,11 +1,170 @@
|
||||||
# Repository Guidelines
|
# AGENTS.md
|
||||||
|
|
||||||
## NixOS Module Notes
|
This file is the operating guide for coding agents working in this repository.
|
||||||
|
|
||||||
- `services.readlater-bot.settings` is rendered to TOML without the token; the token must come from `services.readlater-bot.tokenFile` and is combined at runtime in `/run/readlater-bot/config.toml` to keep secrets out of the Nix store.
|
## Project Summary
|
||||||
- If you override `services.readlater-bot.user`/`group`, ensure the group exists; otherwise systemd fails at step GROUP. Defaults only auto-create the `readlater-bot` user/group when you keep the defaults.
|
|
||||||
|
|
||||||
## Build Checks
|
- Language: Rust (edition 2021).
|
||||||
|
- Binary crate: `readlater-bot`.
|
||||||
|
- Main domain: Telegram bot for managing Read Later / Finished entries in markdown files.
|
||||||
|
- Packaging/deployment: Nix flake + NixOS module (`flake.nix`).
|
||||||
|
- Runtime model: async bot handlers + serialized filesystem writes + retry queue.
|
||||||
|
|
||||||
- Run `cargo check` after changes (patches, features, or other code edits).
|
## Rule Sources Checked
|
||||||
- Create atomic commits after changes.
|
|
||||||
|
- `.cursor/rules/`: not present.
|
||||||
|
- `.cursorrules`: not present.
|
||||||
|
- `.github/copilot-instructions.md`: not present.
|
||||||
|
- Therefore, this `AGENTS.md` is the canonical agent guide.
|
||||||
|
|
||||||
|
## Repository Layout (Current)
|
||||||
|
|
||||||
|
- `src/main.rs`: app wiring, shared types/state, op-application flow.
|
||||||
|
- `src/message_handlers.rs`: Telegram message and slash-command handling.
|
||||||
|
- `src/callback_handlers.rs`: callback query handling.
|
||||||
|
- `src/helpers.rs`: rendering, parsing, utility helpers, retry helpers.
|
||||||
|
- `src/integrations.rs`: git/sync/yt-dlp/python integration helpers.
|
||||||
|
- `src/tests.rs`: unit tests.
|
||||||
|
- `flake.nix`: package and NixOS module definitions.
|
||||||
|
|
||||||
|
## Required Build/Test Commands
|
||||||
|
|
||||||
|
Run these after any meaningful Rust change:
|
||||||
|
|
||||||
|
1. `cargo check`
|
||||||
|
2. `cargo test`
|
||||||
|
|
||||||
|
These are mandatory project checks for agents.
|
||||||
|
|
||||||
|
## Lint/Format Commands
|
||||||
|
|
||||||
|
Use these when touching multiple files or refactoring:
|
||||||
|
|
||||||
|
- Format: `cargo fmt --all`
|
||||||
|
- Format check: `cargo fmt --all -- --check`
|
||||||
|
- Lint: `cargo clippy --all-targets --all-features -- -D warnings`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `clippy` may be slower; run at least before opening PRs or large commits.
|
||||||
|
- If a lint is noisy but valid, prefer code fix over allow attributes.
|
||||||
|
|
||||||
|
## Test Commands (Including Single Test)
|
||||||
|
|
||||||
|
Full suite:
|
||||||
|
|
||||||
|
- `cargo test`
|
||||||
|
|
||||||
|
Run one test by name substring:
|
||||||
|
|
||||||
|
- `cargo test quick_select_index_supports_top_bottom_random`
|
||||||
|
- `cargo test normalize_markdown_links`
|
||||||
|
|
||||||
|
Run exact test path:
|
||||||
|
|
||||||
|
- `cargo test tests::quick_select_index_supports_top_bottom_random`
|
||||||
|
|
||||||
|
Show logs/output for a test:
|
||||||
|
|
||||||
|
- `cargo test tests::quick_select_index_supports_top_bottom_random -- --nocapture`
|
||||||
|
|
||||||
|
Useful for iterative work:
|
||||||
|
|
||||||
|
- `cargo test -- --test-threads=1` (if debugging order-sensitive behavior)
|
||||||
|
|
||||||
|
## Nix/Packaging Commands
|
||||||
|
|
||||||
|
- Flake checks: `nix flake check`
|
||||||
|
- Build package output: `nix build .#default`
|
||||||
|
- Evaluate all systems (optional): `nix flake check --all-systems`
|
||||||
|
|
||||||
|
If prompted, pass `--accept-flake-config` when trusting flake cache settings.
|
||||||
|
|
||||||
|
## Coding Style: Rust
|
||||||
|
|
||||||
|
### Formatting and Structure
|
||||||
|
|
||||||
|
- Follow `rustfmt` defaults; do not hand-format against rustfmt.
|
||||||
|
- Keep functions focused; extract helper functions instead of deeply nested branches.
|
||||||
|
- Prefer early returns (`let Some(x) = ... else { ... };`) for control flow clarity.
|
||||||
|
- Keep lock scope (`Mutex`) as short as practical.
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
|
||||||
|
- Group imports in this order:
|
||||||
|
1. `std`
|
||||||
|
2. external crates
|
||||||
|
3. internal modules (`crate::...` / `super::...`)
|
||||||
|
- Avoid unused imports; remove during edits.
|
||||||
|
- Avoid broad/glob imports in new modules unless already established pattern.
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
- Types/enums/traits: `UpperCamelCase`.
|
||||||
|
- Functions/variables/modules: `snake_case`.
|
||||||
|
- Constants: `SCREAMING_SNAKE_CASE`.
|
||||||
|
- Use domain terms consistently (`entry`, `session`, `peeked`, `quick_seen`, `undo`).
|
||||||
|
|
||||||
|
### Types and Data Modeling
|
||||||
|
|
||||||
|
- Prefer explicit domain types already in code (`EntryBlock`, `ListSession`, `QueuedOp`).
|
||||||
|
- Use `Path`/`PathBuf` for filesystem paths, not raw strings.
|
||||||
|
- Use `Option<T>` for nullable state; avoid sentinel values.
|
||||||
|
- Keep enums exhaustive and explicit for state machines.
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Use `anyhow::Result` for fallible functions.
|
||||||
|
- Add context with `.context(...)` / `.with_context(...)` around I/O/process failures.
|
||||||
|
- Do not use `unwrap`/`expect` in production code paths.
|
||||||
|
- `unwrap` is acceptable in tests when setup failures should panic.
|
||||||
|
- On user-facing failures, send concise Telegram feedback and keep logs actionable.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- Prefer minimal, useful logs (`error!`) for failure points.
|
||||||
|
- Avoid noisy info/debug logs unless they materially help operations.
|
||||||
|
|
||||||
|
### Async and Concurrency
|
||||||
|
|
||||||
|
- Use `tokio::spawn`/`spawn_blocking` appropriately for blocking tasks.
|
||||||
|
- Never hold async mutex guards longer than needed.
|
||||||
|
- Preserve serialized write behavior via existing write lock and queue pattern.
|
||||||
|
|
||||||
|
## Bot UX Conventions to Preserve
|
||||||
|
|
||||||
|
- Ephemeral acknowledgments use `send_ephemeral(...)` and auto-delete.
|
||||||
|
- Non-ephemeral informational/error messages should include delete-button UX helpers.
|
||||||
|
- Keep `/help` command list aligned with implemented commands.
|
||||||
|
- For commands with aliases, keep behavior consistent (for example `/bottom` and `/last`).
|
||||||
|
|
||||||
|
## Data and File Safety
|
||||||
|
|
||||||
|
- Preserve markdown entry boundary behavior and preamble handling.
|
||||||
|
- Keep line-ending normalization and trailing newline behavior.
|
||||||
|
- Use existing atomic write helpers; do not replace with direct unsafe writes.
|
||||||
|
- Preserve dedupe semantics (exact full-block match).
|
||||||
|
|
||||||
|
## NixOS Module Notes (Important)
|
||||||
|
|
||||||
|
- `services.readlater-bot.settings` is rendered to TOML without token.
|
||||||
|
- Token must come from `services.readlater-bot.tokenFile`.
|
||||||
|
- Runtime config is assembled at `/run/readlater-bot/config.toml`.
|
||||||
|
- This avoids writing secrets to the Nix store.
|
||||||
|
- If overriding `services.readlater-bot.user`/`group`, ensure group exists.
|
||||||
|
- Defaults only auto-create `readlater-bot` user/group when defaults are kept.
|
||||||
|
|
||||||
|
## Git and Commit Hygiene for Agents
|
||||||
|
|
||||||
|
- Run `cargo check` after edits; run `cargo test` when behavior changes.
|
||||||
|
- Keep commits atomic and scoped to one logical change.
|
||||||
|
- Do not commit secrets (tokens, API keys, credentials, local env files).
|
||||||
|
- Do not include local scratch directories/files (for example `.sync-x-work/`).
|
||||||
|
|
||||||
|
## Change Checklist (Before Finalizing)
|
||||||
|
|
||||||
|
- Code compiles: `cargo check`.
|
||||||
|
- Tests pass: `cargo test` (or targeted tests + rationale).
|
||||||
|
- Formatting/lint considered for non-trivial edits.
|
||||||
|
- `/help` text updated if command surface changed.
|
||||||
|
- No secrets or unrelated local artifacts included.
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ pub(super) async fn handle_callback(
|
||||||
handle_undos_callback(bot, q, state).await?;
|
handle_undos_callback(bot, q, state).await?;
|
||||||
} else if data.starts_with("undo:") {
|
} else if data.starts_with("undo:") {
|
||||||
handle_undo_callback(bot, q, state).await?;
|
handle_undo_callback(bot, q, state).await?;
|
||||||
|
} else if data.starts_with("reset:") {
|
||||||
|
handle_reset_callback(bot, q, state).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -464,6 +466,41 @@ async fn handle_message_delete_callback(bot: Bot, q: CallbackQuery) -> Result<()
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_reset_callback(
|
||||||
|
bot: Bot,
|
||||||
|
q: CallbackQuery,
|
||||||
|
state: std::sync::Arc<AppState>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let Some(data) = q.data.as_deref() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let scope = data.trim_start_matches("reset:");
|
||||||
|
let chat_id = chat_id_from_user_id(q.from.id.0);
|
||||||
|
|
||||||
|
match scope {
|
||||||
|
"all" => {
|
||||||
|
reset_all_seen(&state).await;
|
||||||
|
send_ephemeral(&bot, chat_id, "Reset list and quick seen state.", ACK_TTL_SECS).await?;
|
||||||
|
}
|
||||||
|
"list" => {
|
||||||
|
reset_peeked(&state).await;
|
||||||
|
send_ephemeral(&bot, chat_id, "Reset list seen state.", ACK_TTL_SECS).await?;
|
||||||
|
}
|
||||||
|
"quick" => {
|
||||||
|
reset_quick_seen(&state).await;
|
||||||
|
send_ephemeral(&bot, chat_id, "Reset quick seen state.", ACK_TTL_SECS).await?;
|
||||||
|
}
|
||||||
|
"cancel" => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_list_callback(
|
async fn handle_list_callback(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
q: CallbackQuery,
|
q: CallbackQuery,
|
||||||
|
|
@ -535,6 +572,27 @@ async fn handle_list_callback(
|
||||||
mode,
|
mode,
|
||||||
page: page + 1,
|
page: page + 1,
|
||||||
};
|
};
|
||||||
|
} else if let (SessionKind::Quick { mode }, ListView::Selected { .. }) =
|
||||||
|
(session.kind.clone(), session.view.clone())
|
||||||
|
{
|
||||||
|
let quick_seen_snapshot = state.quick_seen.lock().await.clone();
|
||||||
|
if let Some(index) = quick_select_index(&session.entries, &quick_seen_snapshot, mode) {
|
||||||
|
session.view = ListView::Selected {
|
||||||
|
return_to: Box::new(ListView::Menu),
|
||||||
|
index,
|
||||||
|
};
|
||||||
|
if let Some(entry) = session.entries.get(index) {
|
||||||
|
state.quick_seen.lock().await.insert(entry.block_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
send_ephemeral(
|
||||||
|
&bot,
|
||||||
|
message.chat.id,
|
||||||
|
"No more unseen quick items. Use /reset.",
|
||||||
|
ACK_TTL_SECS,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"prev" => {
|
"prev" => {
|
||||||
|
|
@ -553,7 +611,7 @@ async fn handle_list_callback(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
"close" => {
|
"close" => {
|
||||||
if matches!(&session.kind, SessionKind::Search { .. }) {
|
if matches!(&session.kind, SessionKind::Search { .. } | SessionKind::Quick { .. }) {
|
||||||
delete_embedded_media_messages(
|
delete_embedded_media_messages(
|
||||||
&bot,
|
&bot,
|
||||||
message.chat.id,
|
message.chat.id,
|
||||||
|
|
@ -610,6 +668,27 @@ async fn handle_list_callback(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if matches!(&session.kind, SessionKind::Quick { .. }) {
|
||||||
|
let quick_seen_snapshot = state.quick_seen.lock().await.clone();
|
||||||
|
if let Some(index) =
|
||||||
|
quick_select_index(&session.entries, &quick_seen_snapshot, QuickSelectMode::Random)
|
||||||
|
{
|
||||||
|
session.view = ListView::Selected {
|
||||||
|
return_to: Box::new(ListView::Menu),
|
||||||
|
index,
|
||||||
|
};
|
||||||
|
if let Some(entry) = session.entries.get(index) {
|
||||||
|
state.quick_seen.lock().await.insert(entry.block_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
send_ephemeral(
|
||||||
|
&bot,
|
||||||
|
message.chat.id,
|
||||||
|
"No unseen quick items. Use /reset.",
|
||||||
|
ACK_TTL_SECS,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"pick" => {
|
"pick" => {
|
||||||
|
|
|
||||||
103
src/helpers.rs
103
src/helpers.rs
|
|
@ -367,6 +367,17 @@ pub(super) fn build_download_quality_keyboard(
|
||||||
InlineKeyboardMarkup::new(rows)
|
InlineKeyboardMarkup::new(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_reset_scope_keyboard() -> InlineKeyboardMarkup {
|
||||||
|
InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![
|
||||||
|
InlineKeyboardButton::callback("All", "reset:all"),
|
||||||
|
InlineKeyboardButton::callback("List", "reset:list"),
|
||||||
|
InlineKeyboardButton::callback("Quick", "reset:quick"),
|
||||||
|
],
|
||||||
|
vec![InlineKeyboardButton::callback("Cancel", "reset:cancel")],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn render_list_view(
|
pub(super) fn render_list_view(
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
session: &ListSession,
|
session: &ListSession,
|
||||||
|
|
@ -420,6 +431,25 @@ pub(super) fn build_menu_view(session_id: &str, session: &ListSession) -> (Strin
|
||||||
|
|
||||||
(text, InlineKeyboardMarkup::new(rows))
|
(text, InlineKeyboardMarkup::new(rows))
|
||||||
}
|
}
|
||||||
|
SessionKind::Quick { mode } => {
|
||||||
|
let mode_label = match mode {
|
||||||
|
QuickSelectMode::Top => "top",
|
||||||
|
QuickSelectMode::Bottom => "bottom",
|
||||||
|
QuickSelectMode::Random => "random",
|
||||||
|
};
|
||||||
|
let text = format!("Quick view ({})", mode_label);
|
||||||
|
let rows = vec![
|
||||||
|
vec![InlineKeyboardButton::callback(
|
||||||
|
"Next",
|
||||||
|
format!("ls:{}:next", session_id),
|
||||||
|
)],
|
||||||
|
vec![InlineKeyboardButton::callback(
|
||||||
|
"Close",
|
||||||
|
format!("ls:{}:close", session_id),
|
||||||
|
)],
|
||||||
|
];
|
||||||
|
(text, InlineKeyboardMarkup::new(rows))
|
||||||
|
}
|
||||||
SessionKind::Search { query } => {
|
SessionKind::Search { query } => {
|
||||||
let text = if count == 0 {
|
let text = if count == 0 {
|
||||||
format!("No matches for \"{}\".", query)
|
format!("No matches for \"{}\".", query)
|
||||||
|
|
@ -468,6 +498,14 @@ pub(super) fn build_peek_view(
|
||||||
let page_display = if total_pages == 0 { 0 } else { page + 1 };
|
let page_display = if total_pages == 0 { 0 } else { page + 1 };
|
||||||
format!("{} (page {})\n", title, page_display)
|
format!("{} (page {})\n", title, page_display)
|
||||||
}
|
}
|
||||||
|
SessionKind::Quick { mode } => {
|
||||||
|
let label = match mode {
|
||||||
|
QuickSelectMode::Top => "top",
|
||||||
|
QuickSelectMode::Bottom => "bottom",
|
||||||
|
QuickSelectMode::Random => "random",
|
||||||
|
};
|
||||||
|
format!("Quick view ({})\n", label)
|
||||||
|
}
|
||||||
SessionKind::Search { query } => {
|
SessionKind::Search { query } => {
|
||||||
if total_pages > 0 {
|
if total_pages > 0 {
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -526,6 +564,16 @@ pub(super) fn build_peek_view(
|
||||||
InlineKeyboardButton::callback("Random", format!("ls:{}:random", session_id)),
|
InlineKeyboardButton::callback("Random", format!("ls:{}:random", session_id)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
SessionKind::Quick { .. } => {
|
||||||
|
rows.push(vec![InlineKeyboardButton::callback(
|
||||||
|
"Next",
|
||||||
|
format!("ls:{}:next", session_id),
|
||||||
|
)]);
|
||||||
|
rows.push(vec![InlineKeyboardButton::callback(
|
||||||
|
"Close",
|
||||||
|
format!("ls:{}:close", session_id),
|
||||||
|
)]);
|
||||||
|
}
|
||||||
SessionKind::Search { .. } => {
|
SessionKind::Search { .. } => {
|
||||||
rows.push(vec![InlineKeyboardButton::callback(
|
rows.push(vec![InlineKeyboardButton::callback(
|
||||||
"Close",
|
"Close",
|
||||||
|
|
@ -572,6 +620,26 @@ pub(super) fn build_selected_view(
|
||||||
format!("ls:{}:back", session_id),
|
format!("ls:{}:back", session_id),
|
||||||
)],
|
)],
|
||||||
],
|
],
|
||||||
|
SessionKind::Quick { .. } => 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(
|
||||||
|
"Next",
|
||||||
|
format!("ls:{}:next", session_id),
|
||||||
|
)],
|
||||||
|
],
|
||||||
SessionKind::Search { .. } => vec![
|
SessionKind::Search { .. } => vec![
|
||||||
vec![InlineKeyboardButton::callback(
|
vec![InlineKeyboardButton::callback(
|
||||||
"Add Resource",
|
"Add Resource",
|
||||||
|
|
@ -719,6 +787,7 @@ pub(super) fn count_visible_entries(session: &ListSession, peeked: &HashSet<Stri
|
||||||
match session.kind {
|
match session.kind {
|
||||||
SessionKind::Search { .. } => session.entries.len(),
|
SessionKind::Search { .. } => session.entries.len(),
|
||||||
SessionKind::List => count_unpeeked_entries(&session.entries, peeked),
|
SessionKind::List => count_unpeeked_entries(&session.entries, peeked),
|
||||||
|
SessionKind::Quick { .. } => session.entries.len(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -787,6 +856,7 @@ pub(super) fn peek_indices_for_session(
|
||||||
match session.kind {
|
match session.kind {
|
||||||
SessionKind::Search { .. } => peek_indices_all(&session.entries, mode, page),
|
SessionKind::Search { .. } => peek_indices_all(&session.entries, mode, page),
|
||||||
SessionKind::List => peek_indices(&session.entries, peeked, mode, page),
|
SessionKind::List => peek_indices(&session.entries, peeked, mode, page),
|
||||||
|
SessionKind::Quick { .. } => peek_indices_all(&session.entries, mode, page),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -908,6 +978,16 @@ pub(super) async fn reset_peeked(state: &std::sync::Arc<AppState>) {
|
||||||
peeked.clear();
|
peeked.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) async fn reset_quick_seen(state: &std::sync::Arc<AppState>) {
|
||||||
|
let mut quick_seen = state.quick_seen.lock().await;
|
||||||
|
quick_seen.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn reset_all_seen(state: &std::sync::Arc<AppState>) {
|
||||||
|
reset_peeked(state).await;
|
||||||
|
reset_quick_seen(state).await;
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn add_undo(
|
pub(super) async fn add_undo(
|
||||||
state: &std::sync::Arc<AppState>,
|
state: &std::sync::Arc<AppState>,
|
||||||
kind: UndoKind,
|
kind: UndoKind,
|
||||||
|
|
@ -1518,18 +1598,27 @@ pub(super) fn parse_command(text: &str) -> Option<&str> {
|
||||||
Some(cmd.split('@').next().unwrap_or(cmd))
|
Some(cmd.split('@').next().unwrap_or(cmd))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn quick_select_index(entries_len: usize, mode: QuickSelectMode) -> Option<usize> {
|
pub(super) fn quick_select_index(
|
||||||
if entries_len == 0 {
|
entries: &[EntryBlock],
|
||||||
|
quick_seen: &HashSet<String>,
|
||||||
|
mode: QuickSelectMode,
|
||||||
|
) -> Option<usize> {
|
||||||
|
let mut unseen_indices: Vec<usize> = entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, entry)| !quick_seen.contains(&entry.block_string()))
|
||||||
|
.map(|(idx, _)| idx)
|
||||||
|
.collect();
|
||||||
|
if unseen_indices.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
match mode {
|
match mode {
|
||||||
QuickSelectMode::Top => Some(0),
|
QuickSelectMode::Top => unseen_indices.first().copied(),
|
||||||
QuickSelectMode::Last => Some(entries_len - 1),
|
QuickSelectMode::Bottom => unseen_indices.last().copied(),
|
||||||
QuickSelectMode::Random => {
|
QuickSelectMode::Random => {
|
||||||
let mut indices: Vec<usize> = (0..entries_len).collect();
|
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
indices.shuffle(&mut rng);
|
unseen_indices.shuffle(&mut rng);
|
||||||
indices.first().copied()
|
unseen_indices.first().copied()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,7 @@ struct UndoSession {
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
enum SessionKind {
|
enum SessionKind {
|
||||||
List,
|
List,
|
||||||
|
Quick { mode: QuickSelectMode },
|
||||||
Search { query: String },
|
Search { query: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,7 +345,7 @@ enum ListMode {
|
||||||
#[derive(Clone, Debug, Copy)]
|
#[derive(Clone, Debug, Copy)]
|
||||||
enum QuickSelectMode {
|
enum QuickSelectMode {
|
||||||
Top,
|
Top,
|
||||||
Last,
|
Bottom,
|
||||||
Random,
|
Random,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -354,6 +355,7 @@ struct AppState {
|
||||||
sessions: Mutex<HashMap<String, ListSession>>,
|
sessions: Mutex<HashMap<String, ListSession>>,
|
||||||
active_sessions: Mutex<HashMap<i64, String>>,
|
active_sessions: Mutex<HashMap<i64, String>>,
|
||||||
peeked: Mutex<HashSet<String>>,
|
peeked: Mutex<HashSet<String>>,
|
||||||
|
quick_seen: Mutex<HashSet<String>>,
|
||||||
undo_sessions: Mutex<HashMap<String, UndoSession>>,
|
undo_sessions: Mutex<HashMap<String, UndoSession>>,
|
||||||
pickers: Mutex<HashMap<String, PickerState>>,
|
pickers: Mutex<HashMap<String, PickerState>>,
|
||||||
add_prompts: Mutex<HashMap<String, AddPrompt>>,
|
add_prompts: Mutex<HashMap<String, AddPrompt>>,
|
||||||
|
|
@ -402,6 +404,7 @@ async fn main() -> Result<()> {
|
||||||
sessions: Mutex::new(HashMap::new()),
|
sessions: Mutex::new(HashMap::new()),
|
||||||
active_sessions: Mutex::new(HashMap::new()),
|
active_sessions: Mutex::new(HashMap::new()),
|
||||||
peeked: Mutex::new(HashSet::new()),
|
peeked: Mutex::new(HashSet::new()),
|
||||||
|
quick_seen: Mutex::new(HashSet::new()),
|
||||||
undo_sessions: Mutex::new(HashMap::new()),
|
undo_sessions: Mutex::new(HashMap::new()),
|
||||||
pickers: Mutex::new(HashMap::new()),
|
pickers: Mutex::new(HashMap::new()),
|
||||||
add_prompts: Mutex::new(HashMap::new()),
|
add_prompts: Mutex::new(HashMap::new()),
|
||||||
|
|
@ -591,4 +594,3 @@ async fn queue_op(state: &std::sync::Arc<AppState>, op: QueuedOp) -> Result<()>
|
||||||
queue.push(op);
|
queue.push(op);
|
||||||
save_queue(&state.queue_path, &queue)
|
save_queue(&state.queue_path, &queue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ pub(super) async fn handle_message(bot: Bot, msg: Message, state: std::sync::Arc
|
||||||
.trim();
|
.trim();
|
||||||
match cmd {
|
match cmd {
|
||||||
"start" | "help" => {
|
"start" | "help" => {
|
||||||
let help = "Send any text to save it. Commands: /start, /help, /add <text>, /list, /top, /last, /random, /search <query>, /delete <query>, /download [url], /undos, /reset_peeked, /pull, /pull theirs, /push, /sync, /sync_x. 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.";
|
let help = "Send any text to save it. Commands: /start, /help, /add <text>, /list, /top, /bottom (or /last), /random, /search <query>, /delete <query>, /download [url], /undos, /reset, /pull, /pull theirs, /push, /sync, /sync_x. 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.";
|
||||||
send_message_with_delete_button(&bot, msg.chat.id, help).await?;
|
send_message_with_delete_button(&bot, msg.chat.id, help).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -169,12 +169,23 @@ pub(super) async fn handle_message(bot: Bot, msg: Message, state: std::sync::Arc
|
||||||
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
"bottom" => {
|
||||||
|
handle_quick_select_command(
|
||||||
|
bot.clone(),
|
||||||
|
msg.clone(),
|
||||||
|
state,
|
||||||
|
QuickSelectMode::Bottom,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
"last" => {
|
"last" => {
|
||||||
handle_quick_select_command(
|
handle_quick_select_command(
|
||||||
bot.clone(),
|
bot.clone(),
|
||||||
msg.clone(),
|
msg.clone(),
|
||||||
state,
|
state,
|
||||||
QuickSelectMode::Last,
|
QuickSelectMode::Bottom,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
||||||
|
|
@ -196,8 +207,8 @@ pub(super) async fn handle_message(bot: Bot, msg: Message, state: std::sync::Arc
|
||||||
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
"reset_peeked" => {
|
"reset" | "reset_peeked" => {
|
||||||
reset_peeked(&state).await;
|
handle_reset_command(&bot, msg.chat.id).await?;
|
||||||
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
let _ = bot.delete_message(msg.chat.id, msg.id).await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -599,8 +610,14 @@ async fn handle_quick_select_command(
|
||||||
mode: QuickSelectMode,
|
mode: QuickSelectMode,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let entries = read_entries(&state.config.read_later_path)?.1;
|
let entries = read_entries(&state.config.read_later_path)?.1;
|
||||||
let Some(index) = quick_select_index(entries.len(), mode) else {
|
let quick_seen_snapshot = state.quick_seen.lock().await.clone();
|
||||||
send_ephemeral(&bot, msg.chat.id, "Read Later is empty.", ACK_TTL_SECS).await?;
|
let Some(index) = quick_select_index(&entries, &quick_seen_snapshot, mode) else {
|
||||||
|
let empty_text = if entries.is_empty() {
|
||||||
|
"Read Later is empty."
|
||||||
|
} else {
|
||||||
|
"Everything in quick view is already seen. Use /reset."
|
||||||
|
};
|
||||||
|
send_ephemeral(&bot, msg.chat.id, empty_text, ACK_TTL_SECS).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -608,7 +625,7 @@ async fn handle_quick_select_command(
|
||||||
let mut session = ListSession {
|
let mut session = ListSession {
|
||||||
id: session_id.clone(),
|
id: session_id.clone(),
|
||||||
chat_id: msg.chat.id.0,
|
chat_id: msg.chat.id.0,
|
||||||
kind: SessionKind::List,
|
kind: SessionKind::Quick { mode },
|
||||||
entries,
|
entries,
|
||||||
view: ListView::Selected {
|
view: ListView::Selected {
|
||||||
return_to: Box::new(ListView::Menu),
|
return_to: Box::new(ListView::Menu),
|
||||||
|
|
@ -619,11 +636,8 @@ async fn handle_quick_select_command(
|
||||||
sent_media_message_ids: Vec::new(),
|
sent_media_message_ids: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if matches!(mode, QuickSelectMode::Random) {
|
|
||||||
session.seen_random.insert(index);
|
|
||||||
}
|
|
||||||
if let Some(entry) = session.entries.get(index) {
|
if let Some(entry) = session.entries.get(index) {
|
||||||
state.peeked.lock().await.insert(entry.block_string());
|
state.quick_seen.lock().await.insert(entry.block_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let peeked_snapshot = state.peeked.lock().await.clone();
|
let peeked_snapshot = state.peeked.lock().await.clone();
|
||||||
|
|
@ -649,6 +663,13 @@ async fn handle_quick_select_command(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_reset_command(bot: &Bot, chat_id: ChatId) -> Result<()> {
|
||||||
|
let text = "Reset seen state for which scope?";
|
||||||
|
let kb = build_reset_scope_keyboard();
|
||||||
|
bot.send_message(chat_id, text).reply_markup(kb).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_search_command(
|
async fn handle_search_command(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
msg: Message,
|
msg: Message,
|
||||||
|
|
|
||||||
30
src/tests.rs
30
src/tests.rs
|
|
@ -301,12 +301,32 @@ fn command_keywords_are_case_insensitive() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn quick_select_index_supports_top_last_random() {
|
fn quick_select_index_supports_top_bottom_random() {
|
||||||
assert_eq!(quick_select_index(0, QuickSelectMode::Top), None);
|
let entries: Vec<EntryBlock> = (0..4).map(|i| entry(&format!("item {}", i))).collect();
|
||||||
assert_eq!(quick_select_index(4, QuickSelectMode::Top), Some(0));
|
let seen = HashSet::new();
|
||||||
assert_eq!(quick_select_index(4, QuickSelectMode::Last), Some(3));
|
assert_eq!(quick_select_index(&[], &seen, QuickSelectMode::Top), None);
|
||||||
let random = quick_select_index(4, QuickSelectMode::Random).unwrap();
|
assert_eq!(
|
||||||
|
quick_select_index(&entries, &seen, QuickSelectMode::Top),
|
||||||
|
Some(0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
quick_select_index(&entries, &seen, QuickSelectMode::Bottom),
|
||||||
|
Some(3)
|
||||||
|
);
|
||||||
|
let random = quick_select_index(&entries, &seen, QuickSelectMode::Random).unwrap();
|
||||||
assert!(random < 4);
|
assert!(random < 4);
|
||||||
|
|
||||||
|
let mut seen_some = HashSet::new();
|
||||||
|
seen_some.insert(entries[0].block_string());
|
||||||
|
seen_some.insert(entries[3].block_string());
|
||||||
|
assert_eq!(
|
||||||
|
quick_select_index(&entries, &seen_some, QuickSelectMode::Top),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
quick_select_index(&entries, &seen_some, QuickSelectMode::Bottom),
|
||||||
|
Some(2)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue