Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Internal** — Service layer (`crates/void-cli/src/service/`) extracting read/write business logic shared by CLI commands and the upcoming MCP server.
- **Reddit** — New connector that polls watched subreddits and surfaces posts matching your keywords and minimum score (one channel conversation per subreddit). Read-only mode uses application-only OAuth (`client_id` + `client_secret`); enabling commenting during `void setup` runs a browser OAuth flow, stores a `refresh_token`, syncs matching posts as comment threads, and lets you reply via `void reply` / `void send --via reddit`. Tune filters at runtime with `void reddit subreddits|keywords|min-score|config`.
- **Slack** — Sync "Saved for Later" items from the Later view during background sync; view with `void slack saved`. Setup wizard documents the required `search:read` scope.
- **Google News** — New read-only connector that watches the public Google News RSS feed. Each configured keyword triggers its own search; matching articles land in your inbox, filtered by a recency window. Configure with `void gn keywords`, `void gn when`, `void gn language`, and `void gn country` (or interactively via `void setup`). Language/country default to `fr`/`FR`; add one connection per edition to follow several.
Expand Down
164 changes: 12 additions & 152 deletions crates/void-cli/src/commands/archive.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
use std::collections::HashMap;
use std::sync::Arc;

use clap::Args;
use tracing::{debug, info, warn};

use void_core::connector::Connector;
use tracing::debug;

use super::connector_factory;
use crate::output;
use crate::service::writes::{self, ArchiveParams};

#[derive(Debug, Args)]
pub struct ArchiveArgs {
Expand All @@ -25,156 +19,22 @@ pub struct ArchiveArgs {
}

pub async fn run(args: &ArchiveArgs) -> anyhow::Result<()> {
if args.before.is_some() {
return run_bulk_before(args).await;
}

if args.message_ids.is_empty() {
anyhow::bail!("at least one message ID is required (or use --before DATE)");
}

run_by_ids(args).await
}

async fn run_bulk_before(args: &ArchiveArgs) -> anyhow::Result<()> {
if !args.message_ids.is_empty() {
if args.before.is_some() && !args.message_ids.is_empty() {
anyhow::bail!("--before cannot be combined with positional message IDs");
}

let date_str = args
.before
.as_deref()
.ok_or_else(|| anyhow::anyhow!("internal error: --before flag without date"))?;
let before_ts = parse_date_to_ts(date_str)
.ok_or_else(|| anyhow::anyhow!("invalid date \"{date_str}\", expected YYYY-MM-DD"))?;

let connector_filter = output::resolve_connector_filter(args.connector.as_deref())?;

debug!(before = date_str, connector = ?connector_filter, "bulk archive before date");
let _cfg = crate::context::config();
let db = crate::context::open_db()?;

let messages = db.bulk_archive_before(before_ts, connector_filter.as_deref())?;
for msg in &messages {
cleanup_cached_files(msg);
}

info!(
count = messages.len(),
before = date_str,
"bulk archive complete"
);
let output = serde_json::json!({ "data": { "archived_count": messages.len() }, "error": null });
println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}

async fn run_by_ids(args: &ArchiveArgs) -> anyhow::Result<()> {
debug!(count = args.message_ids.len(), "archive by IDs");
debug!(count = args.message_ids.len(), before = ?args.before, "archive");
let cfg = crate::context::config();
let db = crate::context::open_db()?;
let store_path = crate::context::store_path();

let mut connectors: HashMap<String, Arc<dyn Connector>> = HashMap::new();
let mut results = Vec::new();

for message_id in &args.message_ids {
let msg = match super::resolve::resolve_message(&db, message_id) {
Ok(m) => m,
Err(_) => {
warn!(message_id, "message not found, skipping");
results.push(serde_json::json!({
"message_id": message_id,
"is_archived": false,
"error": "message not found",
}));
continue;
}
};

let conv = match db.get_conversation(&msg.conversation_id)? {
Some(c) => c,
None => {
warn!(message_id, conversation_id = %msg.conversation_id, "conversation not found, skipping");
results.push(serde_json::json!({
"message_id": message_id,
"is_archived": false,
"error": "conversation not found",
}));
continue;
}
};

let connector_key = format!("{}:{}", msg.connector, msg.connection_id);
if !connectors.contains_key(&connector_key) {
if let Some(connection) = cfg
.find_connection(&msg.connection_id)
.or_else(|| cfg.find_connection_by_connector(&msg.connector))
{
match connector_factory::build_connector(connection, &crate::context::store_path())
{
Ok(c) => {
connectors.insert(connector_key.clone(), c);
}
Err(e) => {
warn!(connector_key, error = %e, "failed to build connector");
}
}
}
}

let remote_synced = if let Some(conn) = connectors.get(&connector_key) {
match conn.archive(&msg.external_id, &conv.external_id).await {
Ok(()) => true,
Err(e) => {
warn!(message_id, error = %e, "remote archive failed, local only");
false
}
}
} else {
false
};

db.mark_message_archived(message_id)?;
cleanup_cached_files(&msg);
info!(message_id, remote_synced, "archived");

results.push(serde_json::json!({
"message_id": message_id,
"is_archived": true,
"remote_synced": remote_synced,
}));
}
let params = ArchiveParams {
message_ids: &args.message_ids,
before: args.before.as_deref(),
connector: args.connector.as_deref(),
};

let output = serde_json::json!({ "data": results, "error": null });
println!("{}", serde_json::to_string_pretty(&output)?);
let value = writes::archive(&db, cfg, &store_path, params).await?;
println!("{}", crate::service::render(&value)?);
Ok(())
}

fn parse_date_to_ts(date: &str) -> Option<i64> {
chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d")
.ok()
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|dt| dt.and_utc().timestamp())
}

/// Delete locally cached files referenced in `metadata.files[].local_path`.
fn cleanup_cached_files(msg: &void_core::models::Message) {
let files = match msg
.metadata
.as_ref()
.and_then(|m| m.get("files"))
.and_then(|f| f.as_array())
{
Some(f) => f,
None => return,
};
for file in files {
if let Some(path) = file.get("local_path").and_then(|v| v.as_str()) {
match std::fs::remove_file(path) {
Ok(()) => debug!(path, "deleted cached file"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => warn!(path, error = %e, "failed to delete cached file"),
}
}
}
}
77 changes: 14 additions & 63 deletions crates/void-cli/src/commands/calendar/list.rs
Original file line number Diff line number Diff line change
@@ -1,73 +1,24 @@
use chrono::{Datelike, Local};

use super::args::CalendarArgs;
use super::parsing::{parse_date_to_ts, parse_day_spec};
use crate::output::{resolve_connector_filter, OutputFormatter};
use crate::service;
use crate::service::reads::{self, CalendarQuery};

pub(super) fn run_list(args: &CalendarArgs) -> anyhow::Result<()> {
let connector = resolve_connector_filter(args.connector.as_deref())?;
let _cfg = crate::context::config();
let db = crate::context::open_db()?;
let formatter = OutputFormatter::new();

let (from, to) = if let Some(day) = &args.day {
let date = parse_day_spec(day)?;
let start = date
.and_hms_opt(0, 0, 0)
.and_then(|dt| dt.and_local_timezone(Local).single())
.map(|dt| dt.timestamp());
let end = (date + chrono::Duration::days(1))
.and_hms_opt(0, 0, 0)
.and_then(|dt| dt.and_local_timezone(Local).single())
.map(|dt| dt.timestamp());
(start, end)
} else {
let today = Local::now().date_naive();
let from = args.from.as_deref().and_then(parse_date_to_ts).or_else(|| {
today
.and_hms_opt(0, 0, 0)
.and_then(|dt| dt.and_local_timezone(Local).single())
.map(|dt| dt.timestamp())
});

let to = args.to.as_deref().and_then(parse_date_to_ts).or_else(|| {
(today + chrono::Duration::days(1))
.and_hms_opt(0, 0, 0)
.and_then(|dt| dt.and_local_timezone(Local).single())
.map(|dt| dt.timestamp())
});
(from, to)
let query = CalendarQuery {
day: args.day.as_deref(),
from: args.from.as_deref(),
to: args.to.as_deref(),
connection: args.connection.as_deref(),
connector: args.connector.as_deref(),
};

let events = db.list_events(
from,
to,
args.connection.as_deref(),
connector.as_deref(),
200,
)?;
formatter.print_events(&events)
let value = reads::calendar_list(&db, &query)?;
println!("{}", service::render(&value)?);
Ok(())
}

pub(super) fn run_week() -> anyhow::Result<()> {
let _cfg = crate::context::config();
let db = crate::context::open_db()?;
let formatter = OutputFormatter::new();

let today = Local::now().date_naive();
let weekday = today.weekday().num_days_from_monday();
let monday = today - chrono::Duration::days(weekday as i64);
let sunday = monday + chrono::Duration::days(7);

let from = monday
.and_hms_opt(0, 0, 0)
.and_then(|dt| dt.and_local_timezone(Local).single())
.map(|dt| dt.timestamp());
let to = sunday
.and_hms_opt(0, 0, 0)
.and_then(|dt| dt.and_local_timezone(Local).single())
.map(|dt| dt.timestamp());

let events = db.list_events(from, to, None, None, 200)?;
formatter.print_events(&events)
let value = reads::calendar_week(&db)?;
println!("{}", service::render(&value)?);
Ok(())
}
2 changes: 1 addition & 1 deletion crates/void-cli/src/commands/calendar/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
mod api;
mod args;
mod list;
mod parsing;
pub(crate) mod parsing;

pub use args::{CalendarArgs, CalendarCommand};

Expand Down
4 changes: 2 additions & 2 deletions crates/void-cli/src/commands/calendar/parsing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ pub(super) fn normalize_datetime(s: &str) -> anyhow::Result<String> {
)
}

pub(super) fn parse_day_spec(spec: &str) -> anyhow::Result<chrono::NaiveDate> {
pub(crate) fn parse_day_spec(spec: &str) -> anyhow::Result<chrono::NaiveDate> {
let today = Local::now().date_naive();
match spec.to_lowercase().as_str() {
"today" => Ok(today),
Expand All @@ -68,7 +68,7 @@ pub(super) fn parse_day_spec(spec: &str) -> anyhow::Result<chrono::NaiveDate> {
}
}

pub(super) fn parse_date_to_ts(date: &str) -> Option<i64> {
pub(crate) fn parse_date_to_ts(date: &str) -> Option<i64> {
chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d")
.ok()
.and_then(|d| d.and_hms_opt(0, 0, 0))
Expand Down
32 changes: 14 additions & 18 deletions crates/void-cli/src/commands/channels.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use clap::Args;
use tracing::debug;

use super::pagination::{build_meta, parse_page};
use crate::output::{resolve_connector_filter, OutputFormatter, CONNECTOR_FILTER_HELP};
use crate::service;
use crate::service::reads::{self, ChannelsQuery};

#[derive(Debug, Args)]
pub struct ChannelsArgs {
Expand All @@ -12,7 +12,7 @@ pub struct ChannelsArgs {
/// Filter by connection (partial match on connection_id)
#[arg(long)]
pub connection: Option<String>,
#[arg(long, help = CONNECTOR_FILTER_HELP)]
#[arg(long, help = crate::output::CONNECTOR_FILTER_HELP)]
pub connector: Option<String>,
/// Maximum number of results to return
#[arg(short = 'n', long, default_value = "100")]
Expand All @@ -27,20 +27,16 @@ pub struct ChannelsArgs {

pub fn run(args: &ChannelsArgs) -> anyhow::Result<()> {
debug!(search = ?args.search, connection = ?args.connection, connector = ?args.connector, size = args.size, page = args.page, "channels");
let connector = resolve_connector_filter(args.connector.as_deref())?;
let _cfg = crate::context::config();
let db = crate::context::open_db()?;
let formatter = OutputFormatter::new();
let offset = parse_page(args.size, args.page)?;

let (channels, total_elements) = db.list_channels_paginated(
args.connection.as_deref(),
connector.as_deref(),
args.search.as_deref(),
args.size,
offset,
args.include_muted,
)?;
let meta = build_meta(args.page, args.size, total_elements);
formatter.print_paginated(&channels, meta)
let query = ChannelsQuery {
search: args.search.as_deref(),
connection: args.connection.as_deref(),
connector: args.connector.as_deref(),
size: args.size,
page: args.page,
include_muted: args.include_muted,
};
let value = reads::channels(&db, &query)?;
println!("{}", service::render(&value)?);
Ok(())
}
Loading