first commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
1596
Cargo.lock
generated
Normal file
1596
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
Cargo.toml
Normal file
20
Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "focusmaters"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
dead_code = "warn"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
expect_used = "allow"
|
||||||
|
nursery = "warn"
|
||||||
|
pedantic = "warn"
|
||||||
|
unwrap_used = "warn"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
reqwest = { version = "^0.12", features = ["json"] }
|
||||||
|
serde = { version = "^1.0", features = ["derive"] }
|
||||||
|
thiserror = "^2.0"
|
||||||
|
tokio = { version = "^1.42", features = ["full"] }
|
||||||
|
time = { version = "^0.3", features = ["serde", "parsing", "formatting", "macros"] }
|
||||||
42
README.md
Normal file
42
README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Focusmaters
|
||||||
|
|
||||||
|
Rust client library for the Focusmate API.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Add to your Cargo.toml:
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
focusmaters = "0.1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use focusmaters::FocusmateClient;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let client = FocusmateClient::new("your-api-key".to_string());
|
||||||
|
|
||||||
|
// Get profile
|
||||||
|
let me = client.get_me().await?;
|
||||||
|
|
||||||
|
// Get sessions for last month
|
||||||
|
let start = OffsetDateTime::now_utc() - time::Duration::days(30);
|
||||||
|
let end = OffsetDateTime::now_utc();
|
||||||
|
let sessions = client.get_sessions(&start, &end).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Profile retrieval
|
||||||
|
- User profile lookup
|
||||||
|
- Session history with auto-pagination for ranges > 1 year
|
||||||
|
- Session partner lookup
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
225
src/lib.rs
Normal file
225
src/lib.rs
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("HTTP error: {0}")]
|
||||||
|
Http(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
#[error("Time format error: {0}")]
|
||||||
|
TimeFormat(#[from] time::error::Format),
|
||||||
|
|
||||||
|
#[error("Session does not have at least 2 users")]
|
||||||
|
NoSessionPartner,
|
||||||
|
|
||||||
|
#[error("Invalid time range: {0}")]
|
||||||
|
InvalidTimeRange(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FocusmateClient {
|
||||||
|
client: Client,
|
||||||
|
api_key: String,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileResponse {
|
||||||
|
pub user: User,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct PartnerProfileResponse {
|
||||||
|
pub user: PublicUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct User {
|
||||||
|
pub email: String,
|
||||||
|
#[serde(rename = "userId")]
|
||||||
|
pub user_id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "totalSessionCount")]
|
||||||
|
pub total_session_count: u32,
|
||||||
|
#[serde(rename = "timeZone")]
|
||||||
|
pub timezone: String,
|
||||||
|
#[serde(rename = "photoUrl")]
|
||||||
|
pub photo_url: String,
|
||||||
|
#[serde(rename = "memberSince")]
|
||||||
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
|
pub member_since: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PublicUser {
|
||||||
|
#[serde(rename = "userId")]
|
||||||
|
pub user_id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "totalSessionCount")]
|
||||||
|
pub total_session_count: u32,
|
||||||
|
#[serde(rename = "timeZone")]
|
||||||
|
pub timezone: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SessionUser {
|
||||||
|
#[serde(rename = "userId")]
|
||||||
|
pub user_id: String,
|
||||||
|
#[serde(rename = "sessionTitle", default)]
|
||||||
|
pub session_title: Option<String>,
|
||||||
|
#[serde(rename = "requestedAt", default)]
|
||||||
|
#[serde(with = "time::serde::rfc3339::option")]
|
||||||
|
pub requested_at: Option<OffsetDateTime>,
|
||||||
|
#[serde(rename = "joinedAt", default)]
|
||||||
|
#[serde(with = "time::serde::rfc3339::option")]
|
||||||
|
pub joined_at: Option<OffsetDateTime>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub completed: bool,
|
||||||
|
#[serde(rename = "activityType", default)]
|
||||||
|
pub activity_type: Option<String>, // Could be an enum if we know all possible values
|
||||||
|
#[serde(default)]
|
||||||
|
pub preferences: Option<SessionPreferences>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SessionResponse {
|
||||||
|
pub sessions: Vec<Session>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Session {
|
||||||
|
#[serde(rename = "sessionId")]
|
||||||
|
pub session_id: String,
|
||||||
|
pub duration: u32,
|
||||||
|
#[serde(rename = "startTime")]
|
||||||
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
|
pub start_time: OffsetDateTime,
|
||||||
|
pub users: Vec<SessionUser>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
/// Gets the partner's profile for this session.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if:
|
||||||
|
/// - The session doesn't have at least two users
|
||||||
|
/// - The API request to get the partner's profile fails
|
||||||
|
pub async fn get_partner_profile(&self, client: &FocusmateClient) -> Result<PublicUser, Error> {
|
||||||
|
if self.users.len() < 2 {
|
||||||
|
return Err(Error::NoSessionPartner);
|
||||||
|
}
|
||||||
|
|
||||||
|
client.get_user(&self.users[1].user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the session was completed.
|
||||||
|
#[must_use]
|
||||||
|
pub fn completed(&self) -> bool {
|
||||||
|
!self.users.is_empty() && self.users[0].completed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SessionPreferences {
|
||||||
|
#[serde(rename = "quietMode")]
|
||||||
|
pub quiet_mode: bool,
|
||||||
|
#[serde(rename = "preferFavorites")]
|
||||||
|
pub prefer_favorites: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FocusmateClient {
|
||||||
|
async fn request<T>(&self, endpoint: &str) -> Result<T, Error>
|
||||||
|
where
|
||||||
|
T: serde::de::DeserializeOwned,
|
||||||
|
{
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(format!("{}{}", self.base_url, endpoint))
|
||||||
|
.header("X-API-KEY", &self.api_key)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
|
||||||
|
response.json().await.map_err(Error::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new `FocusmateClient` with the given API key.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(api_key: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
api_key,
|
||||||
|
base_url: "https://api.focusmate.com/v1".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves the current user's profile.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if:
|
||||||
|
/// - The API request fails
|
||||||
|
/// - Response parsing fails
|
||||||
|
pub async fn get_me(&self) -> Result<User, Error> {
|
||||||
|
self.request::<ProfileResponse>("/me").await.map(|r| r.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves the current user's profile.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if:
|
||||||
|
/// - The API request fails
|
||||||
|
/// - Response parsing fails
|
||||||
|
pub async fn get_user(&self, user_id: &str) -> Result<PublicUser, Error> {
|
||||||
|
self.request::<PartnerProfileResponse>(&format!("/users/{user_id}")).await.map(|r| r.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves sessions between the given start and end times.
|
||||||
|
///
|
||||||
|
/// If the time range exceeds one year, it will automatically split into multiple requests.
|
||||||
|
/// Sessions are returned in reverse chronological order (newest first).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if:
|
||||||
|
/// - The start time is after the end time
|
||||||
|
/// - The API request fails
|
||||||
|
/// - Time formatting fails
|
||||||
|
pub async fn get_sessions(&self, start: &OffsetDateTime, end: &OffsetDateTime) -> Result<Vec<Session>, Error> {
|
||||||
|
if start > end {
|
||||||
|
return Err(Error::InvalidTimeRange("start must be before end".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let one_year = time::Duration::days(365);
|
||||||
|
let mut all_sessions = Vec::new();
|
||||||
|
let mut chunk_start = *start;
|
||||||
|
|
||||||
|
while chunk_start <= *end {
|
||||||
|
let chunk_end = (*end).min(chunk_start + one_year);
|
||||||
|
let chunk_sessions = self.get_sessions_chunk(&chunk_start, &chunk_end).await?;
|
||||||
|
all_sessions.extend(chunk_sessions);
|
||||||
|
chunk_start = chunk_end;
|
||||||
|
if chunk_start >= *end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
all_sessions.sort_by(|a, b| b.start_time.cmp(&a.start_time));
|
||||||
|
Ok(all_sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_sessions_chunk(&self, start: &OffsetDateTime, end: &OffsetDateTime) -> Result<Vec<Session>, Error> {
|
||||||
|
let start_str = start.format(&time::format_description::well_known::Rfc3339)?;
|
||||||
|
let end_str = end.format(&time::format_description::well_known::Rfc3339)?;
|
||||||
|
let endpoint = format!("/sessions?start={start_str}&end={end_str}");
|
||||||
|
self.request::<SessionResponse>(&endpoint).await.map(|r| r.sessions)
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/main.rs
Normal file
39
src/main.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use focusmaters::FocusmateClient;
|
||||||
|
use std::process::Command;
|
||||||
|
use time::macros::datetime;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let api_key = Command::new("keyring")
|
||||||
|
.args(["get", "focusmate-api-key", "felixm"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to get API key")
|
||||||
|
.stdout;
|
||||||
|
|
||||||
|
let api_key = String::from_utf8(api_key).expect("Invalid UTF-8");
|
||||||
|
let client = FocusmateClient::new(api_key.trim().to_string());
|
||||||
|
|
||||||
|
match client.get_me().await {
|
||||||
|
Ok(user) => println!("{user:#?}"),
|
||||||
|
Err(e) => println!("Error: {e:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
match client.get_user("6c267455-a530-4d4c-ba17-2e375932d976").await
|
||||||
|
{
|
||||||
|
Ok(user) => println!("{user:#?}"),
|
||||||
|
Err(e) => println!("Error: {e:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = datetime!(2020-12-06 0:00 UTC);
|
||||||
|
let end = datetime!(2024-12-11 23:59:59 UTC);
|
||||||
|
match client.get_sessions(&start, &end).await {
|
||||||
|
Ok(sessions) => {
|
||||||
|
let n_sessions = sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.completed())
|
||||||
|
.count();
|
||||||
|
println!("User has {n_sessions} completed sessions.");
|
||||||
|
}
|
||||||
|
Err(e) => println!("Error: {e:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user