use crate::error::{DrivenError, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThingsConfig {
#[serde(default = "default_true")]
pub enabled: bool,
pub auth_token: Option<String>,
pub default_list: Option<String>,
#[serde(default)]
pub default_tags: Vec<String>,
}
fn default_true() -> bool {
true
}
impl Default for ThingsConfig {
fn default() -> Self {
Self {
enabled: true,
auth_token: None,
default_list: None,
default_tags: Vec::new(),
}
}
}
impl ThingsConfig {
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
let content = std::fs::read_to_string(path.as_ref())
.map_err(|e| DrivenError::Io(e))?;
Self::parse_sr(&content)
}
fn parse_sr(_content: &str) -> Result<Self> {
Ok(Self::default())
}
pub fn resolve_env_vars(&mut self) {
if let Ok(token) = std::env::var("THINGS_AUTH_TOKEN") {
self.auth_token = Some(token);
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThingsTask {
pub title: String,
pub notes: Option<String>,
pub when: Option<String>,
pub deadline: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub checklist_items: Vec<String>,
pub list_id: Option<String>,
pub heading: Option<String>,
pub completed: bool,
pub canceled: bool,
}
impl Default for ThingsTask {
fn default() -> Self {
Self {
title: String::new(),
notes: None,
when: None,
deadline: None,
tags: Vec::new(),
checklist_items: Vec::new(),
list_id: None,
heading: None,
completed: false,
canceled: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThingsProject {
pub title: String,
pub notes: Option<String>,
pub when: Option<String>,
pub deadline: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
pub area_id: Option<String>,
#[serde(default)]
pub headings: Vec<String>,
#[serde(default)]
pub tasks: Vec<ThingsTask>,
}
impl Default for ThingsProject {
fn default() -> Self {
Self {
title: String::new(),
notes: None,
when: None,
deadline: None,
tags: Vec::new(),
area_id: None,
headings: Vec::new(),
tasks: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct UrlSchemeResult {
pub url: String,
pub opened: bool,
}
pub struct ThingsClient {
config: ThingsConfig,
}
impl ThingsClient {
const URL_SCHEME: &'static str = "things:///";
pub fn new(config: &ThingsConfig) -> Result<Self> {
let mut config = config.clone();
config.resolve_env_vars();
Ok(Self { config })
}
pub fn is_configured(&self) -> bool {
self.config.enabled
}
pub async fn create_task(&self, title: &str, notes: Option<&str>) -> Result<UrlSchemeResult> {
let mut task = ThingsTask::default();
task.title = title.to_string();
task.notes = notes.map(String::from);
task.tags = self.config.default_tags.clone();
task.list_id = self.config.default_list.clone();
self.add_task(&task).await
}
pub async fn add_task(&self, task: &ThingsTask) -> Result<UrlSchemeResult> {
let mut params = vec![format!("title={}", urlencoding::encode(&task.title))];
if let Some(ref notes) = task.notes {
params.push(format!("notes={}", urlencoding::encode(notes)));
}
if let Some(ref when) = task.when {
params.push(format!("when={}", urlencoding::encode(when)));
}
if let Some(ref deadline) = task.deadline {
params.push(format!("deadline={}", urlencoding::encode(deadline)));
}
if !task.tags.is_empty() {
params.push(format!("tags={}", urlencoding::encode(&task.tags.join(","))));
}
if !task.checklist_items.is_empty() {
params.push(format!(
"checklist-items={}",
urlencoding::encode(&task.checklist_items.join("\n"))
));
}
if let Some(ref list) = task.list_id {
params.push(format!("list-id={}", urlencoding::encode(list)));
}
if let Some(ref heading) = task.heading {
params.push(format!("heading={}", urlencoding::encode(heading)));
}
if task.completed {
params.push("completed=true".to_string());
}
if task.canceled {
params.push("canceled=true".to_string());
}
let url = format!("{}add?{}", Self::URL_SCHEME, params.join("&"));
self.open_url(&url).await
}
pub async fn create_project(&self, title: &str, tasks: Vec<&str>) -> Result<UrlSchemeResult> {
let mut project = ThingsProject::default();
project.title = title.to_string();
project.tasks = tasks
.into_iter()
.map(|t| {
let mut task = ThingsTask::default();
task.title = t.to_string();
task
})
.collect();
self.add_project(&project).await
}
pub async fn add_project(&self, project: &ThingsProject) -> Result<UrlSchemeResult> {
let mut params = vec![format!("title={}", urlencoding::encode(&project.title))];
if let Some(ref notes) = project.notes {
params.push(format!("notes={}", urlencoding::encode(notes)));
}
if let Some(ref when) = project.when {
params.push(format!("when={}", urlencoding::encode(when)));
}
if let Some(ref deadline) = project.deadline {
params.push(format!("deadline={}", urlencoding::encode(deadline)));
}
if !project.tags.is_empty() {
params.push(format!("tags={}", urlencoding::encode(&project.tags.join(","))));
}
if let Some(ref area) = project.area_id {
params.push(format!("area-id={}", urlencoding::encode(area)));
}
if !project.headings.is_empty() {
params.push(format!(
"headings={}",
urlencoding::encode(&project.headings.join("\n"))
));
}
if !project.tasks.is_empty() {
let todos: Vec<String> = project
.tasks
.iter()
.map(|t| t.title.clone())
.collect();
params.push(format!("to-dos={}", urlencoding::encode(&todos.join("\n"))));
}
let url = format!("{}add-project?{}", Self::URL_SCHEME, params.join("&"));
self.open_url(&url).await
}
pub async fn add_to_today(&self, title: &str, notes: Option<&str>) -> Result<UrlSchemeResult> {
let mut task = ThingsTask::default();
task.title = title.to_string();
task.notes = notes.map(String::from);
task.when = Some("today".to_string());
self.add_task(&task).await
}
pub async fn add_to_tonight(&self, title: &str, notes: Option<&str>) -> Result<UrlSchemeResult> {
let mut task = ThingsTask::default();
task.title = title.to_string();
task.notes = notes.map(String::from);
task.when = Some("tonight".to_string());
self.add_task(&task).await
}
pub async fn add_to_someday(&self, title: &str, notes: Option<&str>) -> Result<UrlSchemeResult> {
let mut task = ThingsTask::default();
task.title = title.to_string();
task.notes = notes.map(String::from);
task.when = Some("someday".to_string());
self.add_task(&task).await
}
pub async fn show_today(&self) -> Result<UrlSchemeResult> {
let url = format!("{}show?id=today", Self::URL_SCHEME);
self.open_url(&url).await
}
pub async fn show_inbox(&self) -> Result<UrlSchemeResult> {
let url = format!("{}show?id=inbox", Self::URL_SCHEME);
self.open_url(&url).await
}
pub async fn show_upcoming(&self) -> Result<UrlSchemeResult> {
let url = format!("{}show?id=upcoming", Self::URL_SCHEME);
self.open_url(&url).await
}
pub async fn show_anytime(&self) -> Result<UrlSchemeResult> {
let url = format!("{}show?id=anytime", Self::URL_SCHEME);
self.open_url(&url).await
}
pub async fn show_someday(&self) -> Result<UrlSchemeResult> {
let url = format!("{}show?id=someday", Self::URL_SCHEME);
self.open_url(&url).await
}
pub async fn show_logbook(&self) -> Result<UrlSchemeResult> {
let url = format!("{}show?id=logbook", Self::URL_SCHEME);
self.open_url(&url).await
}
pub async fn show_item(&self, item_id: &str) -> Result<UrlSchemeResult> {
let url = format!("{}show?id={}", Self::URL_SCHEME, urlencoding::encode(item_id));
self.open_url(&url).await
}
pub async fn search(&self, query: &str) -> Result<UrlSchemeResult> {
let url = format!("{}search?query={}", Self::URL_SCHEME, urlencoding::encode(query));
self.open_url(&url).await
}
pub async fn update_item(&self, item_id: &str, updates: serde_json::Value) -> Result<UrlSchemeResult> {
if self.config.auth_token.is_none() {
return Err(DrivenError::Config("Auth token required for updates".into()));
}
let json_data = serde_json::json!([{
"type": "to-do",
"id": item_id,
"attributes": updates
}]);
let encoded = urlencoding::encode(&json_data.to_string());
let url = format!(
"{}json?auth-token={}&data={}",
Self::URL_SCHEME,
self.config.auth_token.as_ref().unwrap(),
encoded
);
self.open_url(&url).await
}
pub async fn add_json(&self, items: serde_json::Value) -> Result<UrlSchemeResult> {
let encoded = urlencoding::encode(&items.to_string());
let mut url = format!("{}json?data={}", Self::URL_SCHEME, encoded);
if let Some(ref token) = self.config.auth_token {
url.push_str(&format!("&auth-token={}", token));
}
self.open_url(&url).await
}
async fn open_url(&self, url: &str) -> Result<UrlSchemeResult> {
#[cfg(target_os = "macos")]
{
use tokio::process::Command;
let status = Command::new("open")
.arg(url)
.status()
.await
.map_err(|e| DrivenError::Process(format!("Failed to open URL: {}", e)))?;
Ok(UrlSchemeResult {
url: url.to_string(),
opened: status.success(),
})
}
#[cfg(not(target_os = "macos"))]
{
Ok(UrlSchemeResult {
url: url.to_string(),
opened: false,
})
}
}
}
pub fn quick_add_url(title: &str) -> String {
format!("things:///add?title={}", urlencoding::encode(title))
}
pub fn show_list_url(list: &str) -> String {
format!("things:///show?id={}", urlencoding::encode(list))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = ThingsConfig::default();
assert!(config.enabled);
assert!(config.default_tags.is_empty());
}
#[test]
fn test_quick_add_url() {
let url = quick_add_url("Buy milk");
assert!(url.contains("things:///add"));
assert!(url.contains("title=Buy%20milk"));
}
#[test]
fn test_show_list_url() {
let url = show_list_url("today");
assert_eq!(url, "things:///show?id=today");
}
}