From 4ff35b917c2f255b6ae5adc0267aee91dcbc9db1 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 28 Jan 2026 09:09:19 -0800 Subject: [PATCH] cli: add relative time aliases --- cli/Cargo.lock | 98 +++++++++++++++++ cli/Cargo.toml | 2 + cli/skill/SKILL.md | 11 +- cli/src/dates.rs | 263 +++++++++++++++++++++++++++++++++++++++++++++ cli/src/main.rs | 116 +++++++++++++++++++- 5 files changed, 484 insertions(+), 6 deletions(-) create mode 100644 cli/src/dates.rs diff --git a/cli/Cargo.lock b/cli/Cargo.lock index fb22bb1a..e3dfef5b 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -28,6 +28,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -90,6 +99,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" @@ -179,6 +194,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -730,6 +758,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -846,6 +898,7 @@ dependencies = [ name = "inline-cli" version = "0.2.4" dependencies = [ + "chrono", "clap", "dialoguer", "flate2", @@ -855,6 +908,7 @@ dependencies = [ "prost", "prost-build", "rand 0.8.5", + "regex", "reqwest", "semver", "serde", @@ -1049,6 +1103,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -2340,6 +2403,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index da56b942..6ed14257 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -6,6 +6,7 @@ build = "build.rs" default-run = "inline" [dependencies] +chrono = "0.4" clap = { version = "4.5.23", features = ["derive"] } dialoguer = "0.11.0" futures-util = "0.3.30" @@ -14,6 +15,7 @@ hostname = "0.4.0" mime_guess = "2.0.5" prost = "0.12.6" rand = "0.8.5" +regex = "1.10" reqwest = { version = "0.12.12", features = [ "json", "multipart", diff --git a/cli/skill/SKILL.md b/cli/skill/SKILL.md index a42c7bc4..89d27781 100644 --- a/cli/skill/SKILL.md +++ b/cli/skill/SKILL.md @@ -87,14 +87,15 @@ description: Explain and use the Inline CLI (`inline`) for authentication, chats ### messages -- `inline messages list [--chat-id 123 | --user-id 42] [--limit 50] [--offset-id 456] [--translate en]` +- `inline messages list [--chat-id 123 | --user-id 42] [--limit 50] [--offset-id 456] [--translate en] [--since "yesterday"] [--until "today"]` - List chat history for a chat or DM. - `--translate ` fetches translations and includes them in output. -- `inline messages export [--chat-id 123 | --user-id 42] [--limit 50] [--offset-id 456] --output PATH` +- `inline messages export [--chat-id 123 | --user-id 42] [--limit 50] [--offset-id 456] [--since "1w ago"] [--until "today"] --output PATH` - Export chat history to a JSON file. -- `inline messages search [--chat-id 123 | --user-id 42] --query "onboarding" [--query "alpha beta"] [--limit 50]` +- `inline messages search [--chat-id 123 | --user-id 42] --query "onboarding" [--query "alpha beta"] [--limit 50] [--since "today"] [--until "tomorrow"]` - Search messages in a chat or DM. - `--query` is repeatable; each query can contain space-separated terms (ANDed within a query, ORed across queries). Extra whitespace is collapsed. + - `--since` and `--until` accept relative time expressions like `yesterday`, `2h ago`, `monday`, `2024-01-15`, or RFC3339. - `inline messages get --chat-id 123 --message-id 456 [--translate en]` - Fetch one full message by id (includes media + attachments). - `inline messages send [--chat-id 123 | --user-id 42] [--text "hi"] [--stdin] [--reply-to 456] [--mention USER_ID:OFFSET:LENGTH ...] [--attach PATH ...] [--force-file]` @@ -128,8 +129,12 @@ description: Explain and use the Inline CLI (`inline`) for authentication, chats - JSON: `inline messages search --chat-id 123 --query "design review" --json` - Translate and list messages: - `inline messages list --chat-id 123 --translate en` +- Filter messages by time: + - `inline messages list --chat-id 123 --since "yesterday"` + - `inline messages list --chat-id 123 --since "2h ago" --until "1h ago"` - Export messages to a file: - `inline messages export --chat-id 123 --output ./messages.json` + - `inline messages export --chat-id 123 --since "1w ago" --output ./recent.json` - Send message with multiple attachments: - `inline messages send --chat-id 123 --text "FYI" --attach ./photo.jpg --attach ./spec.pdf` - Reply to a message: diff --git a/cli/src/dates.rs b/cli/src/dates.rs new file mode 100644 index 00000000..8f9ba34f --- /dev/null +++ b/cli/src/dates.rs @@ -0,0 +1,263 @@ +//! Relative time parsing for CLI date flags. +//! +//! Supports human-friendly expressions like "2h ago", "yesterday", "monday". + +use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday}; +use regex::Regex; +use std::sync::LazyLock; + +/// Matches: "2h ago", "30m ago", "1d ago", "2w ago", "1mo ago" +static RELATIVE_AGO_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^(\d+)(mo|w|d|h|m)\s*ago$").expect("valid ago regex")); + +/// Matches: "30m", "2h", "1d" (future, for reminders) +static RELATIVE_FUTURE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^(\d+)(mo|w|d|h|m)$").expect("valid future regex")); + +/// Parse human-friendly time expressions into Unix timestamps. +/// +/// # Supported formats +/// - Relative past: "2h ago", "1d ago", "2w ago", "1mo ago" +/// - Relative future: "30m", "2h", "1d" +/// - Named: "yesterday", "today", "tomorrow" +/// - Weekdays: "monday", "next friday", "this tuesday" +/// - Date: "2024-01-15" (YYYY-MM-DD) +/// - RFC3339: "2024-01-15T10:00:00Z" +/// +/// # Arguments +/// * `input` - The time expression to parse +/// * `now` - Reference time (usually current time in UTC) +/// +/// # Returns +/// Unix timestamp (seconds since epoch) or error message +pub fn parse_relative_time(input: &str, now: DateTime) -> Result { + let raw = input.trim(); + if raw.is_empty() { + return Err("empty time expression".to_string()); + } + + let lower = raw.to_lowercase(); + + // Named expressions + match lower.as_str() { + "yesterday" => return Ok(start_of_day(now - Duration::days(1)).timestamp()), + "today" => return Ok(start_of_day(now).timestamp()), + "tomorrow" => return Ok(start_of_day(now + Duration::days(1)).timestamp()), + _ => {} + } + + // Weekday expressions + if let Some(ts) = parse_weekday(&lower, now) { + return Ok(ts); + } + + // Relative past: "2h ago", "1d ago" + if let Some(caps) = RELATIVE_AGO_RE.captures(&lower) { + let value: i64 = caps[1] + .parse() + .map_err(|_| format!("invalid number in {raw:?}"))?; + if value < 1 { + return Err(format!("invalid relative time {raw:?}")); + } + return apply_relative(now, value, &caps[2], -1); + } + + // Relative future: "30m", "2h" + if let Some(caps) = RELATIVE_FUTURE_RE.captures(&lower) { + let value: i64 = caps[1] + .parse() + .map_err(|_| format!("invalid number in {raw:?}"))?; + if value < 1 { + return Err(format!("invalid relative time {raw:?}")); + } + return apply_relative(now, value, &caps[2], 1); + } + + // Date: YYYY-MM-DD + if let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") { + let dt = date + .and_hms_opt(0, 0, 0) + .ok_or_else(|| format!("invalid date {raw:?}"))?; + return Ok(Utc.from_utc_datetime(&dt).timestamp()); + } + + // RFC3339 + if let Ok(dt) = DateTime::parse_from_rfc3339(raw) { + return Ok(dt.timestamp()); + } + + Err(format!("invalid time expression {raw:?}")) +} + +fn start_of_day(dt: DateTime) -> DateTime { + dt.date_naive() + .and_hms_opt(0, 0, 0) + .map(|naive| Utc.from_utc_datetime(&naive)) + .unwrap_or(dt) +} + +fn parse_weekday(input: &str, now: DateTime) -> Option { + let mut s = input.trim(); + if s.is_empty() { + return None; + } + + let next = if let Some(rest) = s.strip_prefix("next ") { + s = rest.trim(); + true + } else if let Some(rest) = s.strip_prefix("this ") { + s = rest.trim(); + false + } else { + false + }; + + let target_weekday = match s { + "sun" | "sunday" => Weekday::Sun, + "mon" | "monday" => Weekday::Mon, + "tue" | "tues" | "tuesday" => Weekday::Tue, + "wed" | "weds" | "wednesday" => Weekday::Wed, + "thu" | "thur" | "thurs" | "thursday" => Weekday::Thu, + "fri" | "friday" => Weekday::Fri, + "sat" | "saturday" => Weekday::Sat, + _ => return None, + }; + + let base = start_of_day(now); + let current_weekday = base.weekday(); + + let mut delta = (target_weekday.num_days_from_sunday() as i64) + - (current_weekday.num_days_from_sunday() as i64); + if delta < 0 { + delta += 7; + } + if next && delta == 0 { + delta = 7; + } + + Some((base + Duration::days(delta)).timestamp()) +} + +fn apply_relative( + now: DateTime, + value: i64, + unit: &str, + direction: i64, +) -> Result { + let duration = match unit { + "mo" => { + // Approximate months as 30 days + Duration::days(30 * value * direction) + } + "w" => Duration::weeks(value * direction), + "d" => Duration::days(value * direction), + "h" => Duration::hours(value * direction), + "m" => Duration::minutes(value * direction), + _ => return Err(format!("invalid time unit {unit:?}")), + }; + Ok((now + duration).timestamp()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn test_now() -> DateTime { + // Wednesday, January 28, 2026, 15:04:05 UTC + Utc.with_ymd_and_hms(2026, 1, 28, 15, 4, 5) + .single() + .expect("valid datetime") + } + + fn utc_ts(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> i64 { + Utc.with_ymd_and_hms(year, month, day, hour, minute, second) + .single() + .expect("valid datetime") + .timestamp() + } + + #[test] + fn test_named_expressions() { + let now = test_now(); + + // yesterday = 2026-01-27 00:00:00 UTC + let yesterday = parse_relative_time("yesterday", now).expect("yesterday"); + assert_eq!(yesterday, utc_ts(2026, 1, 27, 0, 0, 0)); + + // today = 2026-01-28 00:00:00 UTC + let today = parse_relative_time("today", now).expect("today"); + assert_eq!(today, utc_ts(2026, 1, 28, 0, 0, 0)); + + // tomorrow = 2026-01-29 00:00:00 UTC + let tomorrow = parse_relative_time("tomorrow", now).expect("tomorrow"); + assert_eq!(tomorrow, utc_ts(2026, 1, 29, 0, 0, 0)); + } + + #[test] + fn test_relative_past() { + let now = test_now(); + + let two_hours_ago = parse_relative_time("2h ago", now).expect("2h ago"); + assert_eq!(two_hours_ago, (now - Duration::hours(2)).timestamp()); + + let one_day_ago = parse_relative_time("1d ago", now).expect("1d ago"); + assert_eq!(one_day_ago, (now - Duration::days(1)).timestamp()); + + let two_weeks_ago = parse_relative_time("2w ago", now).expect("2w ago"); + assert_eq!(two_weeks_ago, (now - Duration::weeks(2)).timestamp()); + } + + #[test] + fn test_relative_future() { + let now = test_now(); + + let thirty_mins = parse_relative_time("30m", now).expect("30m"); + assert_eq!(thirty_mins, (now + Duration::minutes(30)).timestamp()); + + let two_hours = parse_relative_time("2h", now).expect("2h"); + assert_eq!(two_hours, (now + Duration::hours(2)).timestamp()); + } + + #[test] + fn test_weekday() { + let now = test_now(); // Wednesday + + // Monday (next occurrence, since today is Wednesday) + let monday = parse_relative_time("monday", now).expect("monday"); + assert_eq!(monday, utc_ts(2026, 2, 2, 0, 0, 0)); + + // next friday + let friday = parse_relative_time("next friday", now).expect("next friday"); + assert_eq!(friday, utc_ts(2026, 1, 30, 0, 0, 0)); + } + + #[test] + fn test_date_formats() { + let now = test_now(); + + // YYYY-MM-DD + let date = parse_relative_time("2026-01-27", now).expect("date"); + assert_eq!(date, utc_ts(2026, 1, 27, 0, 0, 0)); + + // RFC3339 + let rfc = parse_relative_time("2026-01-27T10:00:00Z", now).expect("rfc3339"); + assert_eq!(rfc, utc_ts(2026, 1, 27, 10, 0, 0)); + } + + #[test] + fn test_invalid_input() { + let now = test_now(); + assert!(parse_relative_time("not-a-date", now).is_err()); + assert!(parse_relative_time("", now).is_err()); + assert!(parse_relative_time("0h ago", now).is_err()); + } + + #[test] + fn test_case_insensitive() { + let now = test_now(); + assert!(parse_relative_time("YESTERDAY", now).is_ok()); + assert!(parse_relative_time("Yesterday", now).is_ok()); + assert!(parse_relative_time("2H AGO", now).is_ok()); + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index ba3c7052..28643e84 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,12 +1,14 @@ mod api; mod auth; mod config; +mod dates; mod output; mod protocol; mod realtime; mod state; mod update; +use chrono::{DateTime, Utc}; use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum}; use dialoguer::{Confirm, Input, Select}; use futures_util::StreamExt; @@ -24,6 +26,7 @@ use crate::api::{ }; use crate::auth::AuthStore; use crate::config::Config; +use crate::dates::parse_relative_time; use crate::output::{ AttachmentSummary, ChatListItem, ChatListOutput, ChatParticipantSummary, ChatParticipantsOutput, MediaSummary, MessageListOutput, MessageSummary, PeerSummary, @@ -69,9 +72,13 @@ Examples: inline spaces invite --space-id 31 --email you@example.com inline users list --json inline messages list --chat-id 123 + inline messages list --chat-id 123 --since "yesterday" + inline messages list --chat-id 123 --since "2h ago" --until "1h ago" inline messages list --chat-id 123 --translate en inline messages export --chat-id 123 --output ./messages.json + inline messages export --chat-id 123 --since "1w ago" --output ./recent.json inline messages search --chat-id 123 --query "onboarding" + inline messages search --chat-id 123 --query "urgent" --since "today" inline messages get --chat-id 123 --message-id 456 inline messages send --chat-id 123 --text "hello" inline messages send --chat-id 123 --reply-to 456 --text "on it" @@ -393,6 +400,20 @@ struct MessagesListArgs { help = "Translate messages to language code (e.g., en)" )] translate: Option, + + #[arg( + long, + value_name = "TIME", + help = "Filter messages since time (e.g., yesterday, 2h ago, 2024-01-15)" + )] + since: Option, + + #[arg( + long, + value_name = "TIME", + help = "Filter messages until time (e.g., today, 1d ago, 2024-01-20)" + )] + until: Option, } #[derive(Args)] @@ -408,6 +429,20 @@ struct MessagesSearchArgs { #[arg(long, help = "Maximum number of results to return")] limit: Option, + + #[arg( + long, + value_name = "TIME", + help = "Filter results since time (e.g., yesterday, 2h ago)" + )] + since: Option, + + #[arg( + long, + value_name = "TIME", + help = "Filter results until time (e.g., today, 1d ago)" + )] + until: Option, } #[derive(Args)] @@ -482,6 +517,20 @@ struct MessagesExportArgs { #[arg(long, value_name = "PATH", help = "Output file path")] output: PathBuf, + + #[arg( + long, + value_name = "TIME", + help = "Filter messages since time (e.g., yesterday, 2h ago, 2024-01-15)" + )] + since: Option, + + #[arg( + long, + value_name = "TIME", + help = "Filter messages until time (e.g., today, 1d ago, 2024-01-20)" + )] + until: Option, } #[derive(Args)] @@ -1279,7 +1328,14 @@ async fn run() -> Result<(), Box> { .await?; match result { - proto::rpc_result::Result::GetChatHistory(payload) => { + proto::rpc_result::Result::GetChatHistory(mut payload) => { + let (since_ts, until_ts) = parse_time_filters( + args.since.as_deref(), + args.until.as_deref(), + Utc::now(), + )?; + filter_messages_by_time(&mut payload.messages, since_ts, until_ts); + if cli.json { output::print_json(&payload, json_format)?; } else { @@ -1365,7 +1421,14 @@ async fn run() -> Result<(), Box> { .await?; match result { - proto::rpc_result::Result::SearchMessages(payload) => { + proto::rpc_result::Result::SearchMessages(mut payload) => { + let (since_ts, until_ts) = parse_time_filters( + args.since.as_deref(), + args.until.as_deref(), + Utc::now(), + )?; + filter_messages_by_time(&mut payload.messages, since_ts, until_ts); + if cli.json { output::print_json(&payload, json_format)?; } else { @@ -1534,7 +1597,14 @@ async fn run() -> Result<(), Box> { ) .await?; match result { - proto::rpc_result::Result::GetChatHistory(payload) => { + proto::rpc_result::Result::GetChatHistory(mut payload) => { + let (since_ts, until_ts) = parse_time_filters( + args.since.as_deref(), + args.until.as_deref(), + Utc::now(), + )?; + filter_messages_by_time(&mut payload.messages, since_ts, until_ts); + let output_path = args.output; let payload_text = output::json_string(&payload, json_format)?; if let Some(parent) = output_path.parent() { @@ -3163,6 +3233,46 @@ fn normalize_search_queries(queries: &[String]) -> Result, Box, + until: Option<&str>, + now: DateTime, +) -> Result<(Option, Option), Box> { + let since_ts = since + .map(|value| parse_relative_time(value, now)) + .transpose() + .map_err(|e| format!("invalid --since: {e}"))?; + let until_ts = until + .map(|value| parse_relative_time(value, now)) + .transpose() + .map_err(|e| format!("invalid --until: {e}"))?; + + if let (Some(s), Some(u)) = (since_ts, until_ts) { + if u < s { + return Err("--until must be on or after --since".into()); + } + } + + Ok((since_ts, until_ts)) +} + +fn filter_messages_by_time( + messages: &mut Vec, + since_ts: Option, + until_ts: Option, +) { + if since_ts.is_none() && until_ts.is_none() { + return; + } + + messages.retain(|msg| { + let msg_ts = msg.date; + let after_since = since_ts.map_or(true, |ts| msg_ts >= ts); + let before_until = until_ts.map_or(true, |ts| msg_ts <= ts); + after_since && before_until + }); +} + fn normalize_translation_language(language: &str) -> Result> { let trimmed = language.trim(); if trimmed.is_empty() {