From b705f04fc70971cb8d70b86cf889a9408ccb7eeb Mon Sep 17 00:00:00 2001 From: Brayo Date: Sat, 9 Aug 2025 17:00:36 +0300 Subject: [PATCH 1/4] fix(queries): eliminate double-escaping in regex patterns The serialize_classes function was using serde_json serialization which caused regex patterns to be double-escaped (e.g., 't\.co' became 't\.co'), breaking pattern matching in ActivityWatch queries. Core changes: - Rewrite serialize_classes() to build JSON strings manually instead of using serde_json - Preserve single-escaped regex patterns for proper matching - Only include 'ignore_case' field when true (omit when false) - Only include 'regex' field for non-'none' type categories - Improve error handling in classes deserialization - Add optional fields to ClassSetting struct for better compatibility This fixes the core regex pattern matching issues in ActivityWatch canonical queries that were causing incorrect categorization results. --- aw-client-rust/src/classes.rs | 21 +++++++--- aw-client-rust/src/lib.rs | 2 + aw-client-rust/src/queries.rs | 77 ++++++++++++++++++++++------------- aw-webui | 2 +- 4 files changed, 67 insertions(+), 35 deletions(-) diff --git a/aw-client-rust/src/classes.rs b/aw-client-rust/src/classes.rs index 2d8453e0..fb5e2317 100644 --- a/aw-client-rust/src/classes.rs +++ b/aw-client-rust/src/classes.rs @@ -5,6 +5,7 @@ use log::warn; use rand::Rng; use serde::{Deserialize, Serialize}; +use serde_json; use super::blocking::AwClient as ActivityWatchClient; @@ -14,6 +15,7 @@ pub type CategoryId = Vec; pub struct CategorySpec { #[serde(rename = "type")] pub spec_type: String, + #[serde(default)] pub regex: String, #[serde(default)] pub ignore_case: bool, @@ -21,8 +23,12 @@ pub struct CategorySpec { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClassSetting { + #[serde(default)] + pub id: Option, pub name: Vec, pub rule: CategorySpec, + #[serde(default)] + pub data: Option, } /// Returns the default categorization classes @@ -173,11 +179,16 @@ pub fn get_classes_from_server(host: &str, port: u16) -> Vec<(CategoryId, Catego return default_classes(); } - let class_settings: Vec = serde_json::from_value(setting_value) - .unwrap_or_else(|_| { - warn!("Failed to deserialize classes setting, using default classes"); - return vec![]; - }); + let class_settings: Vec = match serde_json::from_value(setting_value) { + Ok(classes) => classes, + Err(e) => { + warn!( + "Failed to deserialize classes setting: {}, using default classes", + e + ); + return default_classes(); + } + }; // Convert ClassSetting to (CategoryId, CategorySpec) format class_settings diff --git a/aw-client-rust/src/lib.rs b/aw-client-rust/src/lib.rs index d6bd0d1d..5c6be763 100644 --- a/aw-client-rust/src/lib.rs +++ b/aw-client-rust/src/lib.rs @@ -123,6 +123,8 @@ impl AwClient { .map(|(start, stop)| format!("{}/{}", start, stop)) .collect(); + let query_lines: Vec<&str> = query.split('\n').collect(); + // Result is a sequence, one element per timeperiod self.client .post(url) diff --git a/aw-client-rust/src/queries.rs b/aw-client-rust/src/queries.rs index 92648d3f..7129b9a9 100644 --- a/aw-client-rust/src/queries.rs +++ b/aw-client-rust/src/queries.rs @@ -165,21 +165,40 @@ impl QueryParams { } /// Helper function to serialize classes in the format expected by the categorize function +/// This version builds the query string directly without JSON serialization to avoid double-escaping fn serialize_classes(classes: &[ClassRule]) -> String { - // Convert Vec<(CategoryId, CategorySpec)> to the JSON format expected by categorize - let serialized_classes: Vec<(Vec, serde_json::Value)> = classes - .iter() - .map(|(category_id, category_spec)| { - let spec_json = serde_json::json!({ - "type": category_spec.spec_type, - "regex": category_spec.regex, - "ignore_case": category_spec.ignore_case - }); - (category_id.clone(), spec_json) - }) - .collect(); - - serde_json::to_string(&serialized_classes).unwrap_or_else(|_| "[]".to_string()) + let mut parts = Vec::new(); + + for (category_id, category_spec) in classes { + // Build category array string manually: ["Work", "Programming"] + let category_str = format!( + "[{}]", + category_id + .iter() + .map(|s| format!("\"{}\"", s)) + .collect::>() + .join(", ") + ); + + // Build spec object manually to avoid JSON escaping regex patterns + let mut spec_parts = Vec::new(); + spec_parts.push(format!("\"type\": \"{}\"", category_spec.spec_type)); + + // Only include regex for non-"none" types, and use raw pattern without escaping + if category_spec.spec_type != "none" { + spec_parts.push(format!("\"regex\": \"{}\"", category_spec.regex)); + } + + // Always include ignore_case field + spec_parts.push(format!("\"ignore_case\": {}", category_spec.ignore_case)); + + let spec_str = format!("{{{}}}", spec_parts.join(", ")); + + // Build the tuple [category, spec] + parts.push(format!("[{}, {}]", category_str, spec_str)); + } + + format!("[{}]", parts.join(", ")) } fn build_desktop_canonical_events(params: &DesktopQueryParams) -> String { @@ -195,7 +214,7 @@ fn build_desktop_canonical_events(params: &DesktopQueryParams) -> String { if params.base.filter_afk { query.push(format!( "not_afk = flood(query_bucket(find_bucket(\"{}\"))); - not_afk = filter_keyvals(not_afk, \"status\", [\"not-afk\"])", +not_afk = filter_keyvals(not_afk, \"status\", [\"not-afk\"])", escape_doublequote(¶ms.bid_afk) )); } @@ -207,7 +226,7 @@ fn build_desktop_canonical_events(params: &DesktopQueryParams) -> String { if params.base.include_audible { query.push( "audible_events = filter_keyvals(browser_events, \"audible\", [true]); - not_afk = period_union(not_afk, audible_events)" +not_afk = period_union(not_afk, audible_events)" .to_string(), ); } @@ -221,7 +240,7 @@ fn build_desktop_canonical_events(params: &DesktopQueryParams) -> String { // Add categorization if classes specified if !params.base.classes.is_empty() { query.push(format!( - "events = categorize(events, {})", + "events = categorize(events, {});", serialize_classes(¶ms.base.classes) )); } @@ -252,7 +271,7 @@ fn build_android_canonical_events(params: &AndroidQueryParams) -> String { // Add categorization if classes specified if !params.base.classes.is_empty() { query.push(format!( - "events = categorize(events, {})", + "events = categorize(events, {});", serialize_classes(¶ms.base.classes) )); } @@ -269,18 +288,19 @@ fn build_android_canonical_events(params: &AndroidQueryParams) -> String { } fn build_browser_events(params: &DesktopQueryParams) -> String { - let mut query = String::from("browser_events = [];\n"); + let mut query = String::from("browser_events = [];"); for browser_bucket in ¶ms.base.bid_browsers { for (browser_name, app_names) in BROWSER_APPNAMES.entries() { if browser_bucket.contains(browser_name) { query.push_str(&format!( - "events_{0} = flood(query_bucket(\"{1}\")); - window_{0} = filter_keyvals(events, \"app\", {2}); - events_{0} = filter_period_intersect(events_{0}, window_{0}); - events_{0} = split_url_events(events_{0}); - browser_events = concat(browser_events, events_{0}); - browser_events = sort_by_timestamp(browser_events);\n", + " +events_{0} = flood(query_bucket(\"{1}\")); +window_{0} = filter_keyvals(events, \"app\", {2}); +events_{0} = filter_period_intersect(events_{0}, window_{0}); +events_{0} = split_url_events(events_{0}); +browser_events = concat(browser_events, events_{0}); +browser_events = sort_by_timestamp(browser_events)", browser_name, escape_doublequote(browser_bucket), serde_json::to_string(app_names).unwrap() @@ -288,7 +308,6 @@ fn build_browser_events(params: &DesktopQueryParams) -> String { } } } - query } @@ -414,9 +433,9 @@ mod tests { assert!(serialized.contains("Programming")); assert!(serialized.contains("Google Docs")); assert!(serialized.contains("GitHub|vim")); - assert!(serialized.contains("\"type\":\"regex\"")); - assert!(serialized.contains("\"ignore_case\":false")); - assert!(serialized.contains("\"ignore_case\":true")); + assert!(serialized.contains("\"type\": \"regex\"")); + assert!(serialized.contains("\"ignore_case\": false")); + assert!(serialized.contains("\"ignore_case\": true")); } #[test] diff --git a/aw-webui b/aw-webui index 0cf78317..291da6f2 160000 --- a/aw-webui +++ b/aw-webui @@ -1 +1 @@ -Subproject commit 0cf7831771d9cad9e25954eaed99c77d5a7c6e10 +Subproject commit 291da6f2c5e7a6b896f23a4eec5ffed9874321ba From 3197f0e17ba2a7f05812ccf1debd4fff477005fc Mon Sep 17 00:00:00 2001 From: Brayo Date: Thu, 21 Aug 2025 20:26:34 +0300 Subject: [PATCH 2/4] savepoint --- aw-client-rust/src/classes.rs | 58 -------- aw-client-rust/src/lib.rs | 2 - aw-client-rust/src/queries.rs | 251 +++++++++++++++++++++++++++++----- 3 files changed, 219 insertions(+), 92 deletions(-) diff --git a/aw-client-rust/src/classes.rs b/aw-client-rust/src/classes.rs index fb5e2317..4312bad1 100644 --- a/aw-client-rust/src/classes.rs +++ b/aw-client-rust/src/classes.rs @@ -146,61 +146,3 @@ pub fn default_classes() -> Vec<(CategoryId, CategorySpec)> { ), ] } - -/// Get classes from server-side settings using default localhost:5600. -/// Might throw an error if not set yet, in which case we use the default classes as a fallback. -pub fn get_classes() -> Vec<(CategoryId, CategorySpec)> { - get_classes_from_server("localhost", 5600) -} - -/// Get classes from server-side settings with custom host and port. -/// Might throw an error if not set yet, in which case we use the default classes as a fallback. -pub fn get_classes_from_server(host: &str, port: u16) -> Vec<(CategoryId, CategorySpec)> { - let mut rng = rand::rng(); - let random_int = rng.random_range(0..10001); - let client_id = format!("get-setting-{}", random_int); - - // Create a client with a random ID, similar to the Python implementation - let awc = match ActivityWatchClient::new(host, port, &client_id) { - Ok(client) => client, - Err(_) => { - warn!( - "Failed to create ActivityWatch client for {}:{}, using default classes", - host, port - ); - return default_classes(); - } - }; - - awc.get_setting("classes") - .map(|setting_value| { - // Try to deserialize the setting into Vec - if setting_value.is_null() { - return default_classes(); - } - - let class_settings: Vec = match serde_json::from_value(setting_value) { - Ok(classes) => classes, - Err(e) => { - warn!( - "Failed to deserialize classes setting: {}, using default classes", - e - ); - return default_classes(); - } - }; - - // Convert ClassSetting to (CategoryId, CategorySpec) format - class_settings - .into_iter() - .map(|class| (class.name, class.rule)) - .collect() - }) - .unwrap_or_else(|_| { - warn!( - "Failed to get classes from server {}:{}, using default classes as fallback", - host, port - ); - default_classes() - }) -} diff --git a/aw-client-rust/src/lib.rs b/aw-client-rust/src/lib.rs index 5c6be763..d6bd0d1d 100644 --- a/aw-client-rust/src/lib.rs +++ b/aw-client-rust/src/lib.rs @@ -123,8 +123,6 @@ impl AwClient { .map(|(start, stop)| format!("{}/{}", start, stop)) .collect(); - let query_lines: Vec<&str> = query.split('\n').collect(); - // Result is a sequence, one element per timeperiod self.client .post(url) diff --git a/aw-client-rust/src/queries.rs b/aw-client-rust/src/queries.rs index 7129b9a9..c32d443c 100644 --- a/aw-client-rust/src/queries.rs +++ b/aw-client-rust/src/queries.rs @@ -36,6 +36,7 @@ //! ``` use crate::classes::{CategoryId, CategorySpec}; +use crate::AwClient; use serde::{Deserialize, Serialize}; /// Browser application names mapped by browser type @@ -78,7 +79,6 @@ pub static BROWSER_APPNAMES: phf::Map<&'static str, &'static [&'static str]> = p "vivaldi" => &["Vivaldi-stable", "Vivaldi-snapshot", "vivaldi.exe"], }; -pub const DEFAULT_LIMIT: u32 = 100; /// Type alias for categorization classes pub type ClassRule = (CategoryId, CategorySpec); @@ -135,33 +135,6 @@ impl QueryParams { QueryParams::Android(params) => build_android_canonical_events(params), } } - - /// Build canonical events query string with automatic class fetching if not provided - pub fn canonical_events_with_classes(&self) -> String { - self.canonical_events_with_classes_from_server("localhost", 5600) - } - - /// Build canonical events query string with automatic class fetching from custom server - pub fn canonical_events_with_classes_from_server(&self, host: &str, port: u16) -> String { - match self { - QueryParams::Desktop(params) => { - let mut params_with_classes = params.clone(); - if params_with_classes.base.classes.is_empty() { - params_with_classes.base.classes = - crate::classes::get_classes_from_server(host, port); - } - build_desktop_canonical_events(¶ms_with_classes) - } - QueryParams::Android(params) => { - let mut params_with_classes = params.clone(); - if params_with_classes.base.classes.is_empty() { - params_with_classes.base.classes = - crate::classes::get_classes_from_server(host, port); - } - build_android_canonical_events(¶ms_with_classes) - } - } - } } /// Helper function to serialize classes in the format expected by the categorize function @@ -201,7 +174,7 @@ fn serialize_classes(classes: &[ClassRule]) -> String { format!("[{}]", parts.join(", ")) } -fn build_desktop_canonical_events(params: &DesktopQueryParams) -> String { +pub fn build_desktop_canonical_events(params: &DesktopQueryParams) -> String { let mut query = Vec::new(); // Fetch window events @@ -256,7 +229,7 @@ not_afk = period_union(not_afk, audible_events)" query.join(";\n") } -fn build_android_canonical_events(params: &AndroidQueryParams) -> String { +pub fn build_android_canonical_events(params: &AndroidQueryParams) -> String { let mut query = Vec::new(); // Fetch app events @@ -287,7 +260,7 @@ fn build_android_canonical_events(params: &AndroidQueryParams) -> String { query.join(";\n") } -fn build_browser_events(params: &DesktopQueryParams) -> String { +pub fn build_browser_events(params: &DesktopQueryParams) -> String { let mut query = String::from("browser_events = [];"); for browser_bucket in ¶ms.base.bid_browsers { @@ -311,10 +284,143 @@ browser_events = sort_by_timestamp(browser_events)", query } -/// Build a full desktop query +/// Build a full desktop query using default localhost:5600 configuration pub fn full_desktop_query(params: &DesktopQueryParams) -> String { let mut query = QueryParams::Desktop(params.clone()).canonical_events_with_classes(); + // Add basic event aggregations + query.push_str(&format!( + " + title_events = sort_by_duration(merge_events_by_keys(events, [\"app\", \"title\"])); + app_events = sort_by_duration(merge_events_by_keys(title_events, [\"app\"])); + cat_events = sort_by_duration(merge_events_by_keys(events, [\"$category\"])); + duration = sum_durations(events); + ", + )); + + // Add browser-specific query parts if browser buckets exist + if !params.base.bid_browsers.is_empty() { + query.push_str(&format!( + " + browser_events = split_url_events(browser_events); + browser_urls = merge_events_by_keys(browser_events, [\"url\"]); + browser_urls = sort_by_duration(browser_urls); + browser_domains = merge_events_by_keys(browser_events, [\"$domain\"]); + browser_domains = sort_by_duration(browser_domains); + browser_duration = sum_durations(browser_events); + ", + )); + } else { + query.push_str( + " + browser_events = []; + browser_urls = []; + browser_domains = []; + browser_duration = 0; + ", + ); + } + + // Add return statement + query.push_str( + " + RETURN = { + \"events\": events, + \"window\": { + \"app_events\": app_events, + \"title_events\": title_events, + \"cat_events\": cat_events, + \"active_events\": not_afk, + \"duration\": duration + }, + \"browser\": { + \"domains\": browser_domains, + \"urls\": browser_urls, + \"duration\": browser_duration + } + }; + ", + ); + + query +} + +/// Build a full desktop query using client configuration +pub fn full_desktop_query_from_client( + params: &DesktopQueryParams, + client: &crate::AwClient, +) -> String { + let mut query = + QueryParams::Desktop(params.clone()).canonical_events_with_classes_from_client(client); + + // Add basic event aggregations + query.push_str(&format!( + " + title_events = sort_by_duration(merge_events_by_keys(events, [\"app\", \"title\"])); + app_events = sort_by_duration(merge_events_by_keys(title_events, [\"app\"])); + cat_events = sort_by_duration(merge_events_by_keys(events, [\"$category\"])); + app_events = limit_events(app_events, {}); + title_events = limit_events(title_events, {}); + duration = sum_durations(events); + ", + DEFAULT_LIMIT, DEFAULT_LIMIT + )); + + // Add browser-specific query parts if browser buckets exist + if !params.base.bid_browsers.is_empty() { + query.push_str(&format!( + " + browser_events = split_url_events(browser_events); + browser_urls = merge_events_by_keys(browser_events, [\"url\"]); + browser_urls = sort_by_duration(browser_urls); + browser_domains = merge_events_by_keys(browser_events, [\"$domain\"]); + browser_domains = sort_by_duration(browser_domains); + browser_duration = sum_durations(browser_events); + " + )); + } else { + query.push_str( + " + browser_events = []; + browser_urls = []; + browser_domains = []; + browser_duration = 0; + ", + ); + } + + // Add return statement + query.push_str( + " + RETURN = { + \"events\": events, + \"window\": { + \"app_events\": app_events, + \"title_events\": title_events, + \"cat_events\": cat_events, + \"active_events\": not_afk, + \"duration\": duration + }, + \"browser\": { + \"domains\": browser_domains, + \"urls\": browser_urls, + \"duration\": browser_duration + } + }; + ", + ); + + query +} + +/// Build a full desktop query using blocking client configuration +pub fn full_desktop_query_from_blocking_client( + params: &DesktopQueryParams, + client: &crate::blocking::AwClient, +) -> String { + let mut query = QueryParams::Desktop(params.clone()) + .canonical_events_with_classes_from_blocking_client(client); + // Add basic event aggregations query.push_str(&format!( " @@ -490,4 +596,85 @@ mod tests { assert!(query.contains("events = categorize")); assert!(query.contains("test")); } + + #[test] + fn test_canonical_events_with_client_config() { + use crate::AwClient; + + let params = DesktopQueryParams { + base: QueryParamsBase { + bid_browsers: vec![], + classes: vec![], // Empty - would fetch from server if available + filter_classes: vec![], + filter_afk: true, + include_audible: true, + }, + bid_window: "test-window".to_string(), + bid_afk: "test-afk".to_string(), + }; + + // Test with custom port client + if let Ok(client) = AwClient::new("localhost", 8080, "test-client") { + let query_params = QueryParams::Desktop(params.clone()); + let query = query_params.canonical_events_with_classes_from_client(&client); + + // Should contain basic query structure + assert!(query.contains("events = flood")); + assert!(query.contains("test-window")); + } + + // Test with blocking client + use crate::blocking::AwClient as BlockingClient; + if let Ok(blocking_client) = BlockingClient::new("localhost", 9090, "test-blocking-client") + { + let query_params = QueryParams::Desktop(params); + let query = + query_params.canonical_events_with_classes_from_blocking_client(&blocking_client); + + // Should contain basic query structure + assert!(query.contains("events = flood")); + assert!(query.contains("test-window")); + } + } + + #[test] + fn test_full_desktop_query_from_client() { + use crate::AwClient; + + let params = DesktopQueryParams { + base: QueryParamsBase { + bid_browsers: vec!["aw-watcher-web-chrome".to_string()], + classes: vec![], + filter_classes: vec![], + filter_afk: true, + include_audible: true, + }, + bid_window: "test-window".to_string(), + bid_afk: "test-afk".to_string(), + }; + + // Test the client-aware full desktop query + if let Ok(client) = AwClient::new("localhost", 8080, "test-client") { + let query = full_desktop_query_from_client(¶ms, &client); + + // Should contain all expected parts + assert!(query.contains("events = flood")); + assert!(query.contains("title_events = sort_by_duration")); + assert!(query.contains("browser_events")); + assert!(query.contains("RETURN")); + } + + // Test the blocking client version + use crate::blocking::AwClient as BlockingClient; + if let Ok(blocking_client) = BlockingClient::new("localhost", 9090, "test-blocking-client") + { + let query = full_desktop_query_from_blocking_client(¶ms, &blocking_client); + + // Should contain all expected parts + assert!(query.contains("events = flood")); + assert!(query.contains("title_events = sort_by_duration")); + assert!(query.contains("browser_events")); + assert!(query.contains("RETURN")); + } + } } From eac962cfce522c6de0a28760d71dfc2823819a92 Mon Sep 17 00:00:00 2001 From: Brayo Date: Thu, 21 Aug 2025 20:27:02 +0300 Subject: [PATCH 3/4] s --- aw-client-rust/src/classes.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/aw-client-rust/src/classes.rs b/aw-client-rust/src/classes.rs index 4312bad1..1e6c2236 100644 --- a/aw-client-rust/src/classes.rs +++ b/aw-client-rust/src/classes.rs @@ -2,13 +2,9 @@ //! //! Taken from default classes in aw-webui -use log::warn; -use rand::Rng; use serde::{Deserialize, Serialize}; use serde_json; -use super::blocking::AwClient as ActivityWatchClient; - pub type CategoryId = Vec; #[derive(Debug, Clone, Serialize, Deserialize)] From 7dfaf6ac6c0389f9fb55413db2bda72cf19551a3 Mon Sep 17 00:00:00 2001 From: Brayo Date: Thu, 21 Aug 2025 20:34:09 +0300 Subject: [PATCH 4/4] fix: remove unnecessary functions in query class Keep feature parity with aw-client --- aw-client-rust/src/lib.rs | 2 +- aw-client-rust/src/queries.rs | 154 ++-------------------------------- 2 files changed, 6 insertions(+), 150 deletions(-) diff --git a/aw-client-rust/src/lib.rs b/aw-client-rust/src/lib.rs index d6bd0d1d..42368604 100644 --- a/aw-client-rust/src/lib.rs +++ b/aw-client-rust/src/lib.rs @@ -36,7 +36,7 @@ impl std::fmt::Debug for AwClient { } fn get_hostname() -> String { - return gethostname::gethostname().to_string_lossy().to_string(); + gethostname::gethostname().to_string_lossy().to_string() } impl AwClient { diff --git a/aw-client-rust/src/queries.rs b/aw-client-rust/src/queries.rs index c32d443c..50493865 100644 --- a/aw-client-rust/src/queries.rs +++ b/aw-client-rust/src/queries.rs @@ -36,7 +36,6 @@ //! ``` use crate::classes::{CategoryId, CategorySpec}; -use crate::AwClient; use serde::{Deserialize, Serialize}; /// Browser application names mapped by browser type @@ -286,169 +285,26 @@ browser_events = sort_by_timestamp(browser_events)", /// Build a full desktop query using default localhost:5600 configuration pub fn full_desktop_query(params: &DesktopQueryParams) -> String { - let mut query = QueryParams::Desktop(params.clone()).canonical_events_with_classes(); + let mut query = QueryParams::Desktop(params.clone()).canonical_events(); // Add basic event aggregations - query.push_str(&format!( - " - title_events = sort_by_duration(merge_events_by_keys(events, [\"app\", \"title\"])); - app_events = sort_by_duration(merge_events_by_keys(title_events, [\"app\"])); - cat_events = sort_by_duration(merge_events_by_keys(events, [\"$category\"])); - duration = sum_durations(events); - ", - )); - - // Add browser-specific query parts if browser buckets exist - if !params.base.bid_browsers.is_empty() { - query.push_str(&format!( - " - browser_events = split_url_events(browser_events); - browser_urls = merge_events_by_keys(browser_events, [\"url\"]); - browser_urls = sort_by_duration(browser_urls); - browser_domains = merge_events_by_keys(browser_events, [\"$domain\"]); - browser_domains = sort_by_duration(browser_domains); - browser_duration = sum_durations(browser_events); - ", - )); - } else { - query.push_str( - " - browser_events = []; - browser_urls = []; - browser_domains = []; - browser_duration = 0; - ", - ); - } - - // Add return statement - query.push_str( - " - RETURN = { - \"events\": events, - \"window\": { - \"app_events\": app_events, - \"title_events\": title_events, - \"cat_events\": cat_events, - \"active_events\": not_afk, - \"duration\": duration - }, - \"browser\": { - \"domains\": browser_domains, - \"urls\": browser_urls, - \"duration\": browser_duration - } - }; - ", - ); - - query -} - -/// Build a full desktop query using client configuration -pub fn full_desktop_query_from_client( - params: &DesktopQueryParams, - client: &crate::AwClient, -) -> String { - let mut query = - QueryParams::Desktop(params.clone()).canonical_events_with_classes_from_client(client); - - // Add basic event aggregations - query.push_str(&format!( - " - title_events = sort_by_duration(merge_events_by_keys(events, [\"app\", \"title\"])); - app_events = sort_by_duration(merge_events_by_keys(title_events, [\"app\"])); - cat_events = sort_by_duration(merge_events_by_keys(events, [\"$category\"])); - app_events = limit_events(app_events, {}); - title_events = limit_events(title_events, {}); - duration = sum_durations(events); - ", - DEFAULT_LIMIT, DEFAULT_LIMIT - )); - - // Add browser-specific query parts if browser buckets exist - if !params.base.bid_browsers.is_empty() { - query.push_str(&format!( - " - browser_events = split_url_events(browser_events); - browser_urls = merge_events_by_keys(browser_events, [\"url\"]); - browser_urls = sort_by_duration(browser_urls); - browser_domains = merge_events_by_keys(browser_events, [\"$domain\"]); - browser_domains = sort_by_duration(browser_domains); - browser_duration = sum_durations(browser_events); - " - )); - } else { - query.push_str( - " - browser_events = []; - browser_urls = []; - browser_domains = []; - browser_duration = 0; - ", - ); - } - - // Add return statement - query.push_str( - " - RETURN = { - \"events\": events, - \"window\": { - \"app_events\": app_events, - \"title_events\": title_events, - \"cat_events\": cat_events, - \"active_events\": not_afk, - \"duration\": duration - }, - \"browser\": { - \"domains\": browser_domains, - \"urls\": browser_urls, - \"duration\": browser_duration - } - }; - ", - ); - - query -} - -/// Build a full desktop query using blocking client configuration -pub fn full_desktop_query_from_blocking_client( - params: &DesktopQueryParams, - client: &crate::blocking::AwClient, -) -> String { - let mut query = QueryParams::Desktop(params.clone()) - .canonical_events_with_classes_from_blocking_client(client); - - // Add basic event aggregations - query.push_str(&format!( - " + query.push_str(&" title_events = sort_by_duration(merge_events_by_keys(events, [\"app\", \"title\"])); app_events = sort_by_duration(merge_events_by_keys(title_events, [\"app\"])); cat_events = sort_by_duration(merge_events_by_keys(events, [\"$category\"])); - app_events = limit_events(app_events, {}); - title_events = limit_events(title_events, {}); duration = sum_durations(events); - ", - DEFAULT_LIMIT, DEFAULT_LIMIT - )); + ".to_string()); // Add browser-specific query parts if browser buckets exist if !params.base.bid_browsers.is_empty() { - query.push_str(&format!( - " + query.push_str(&" browser_events = split_url_events(browser_events); browser_urls = merge_events_by_keys(browser_events, [\"url\"]); browser_urls = sort_by_duration(browser_urls); - browser_urls = limit_events(browser_urls, {}); browser_domains = merge_events_by_keys(browser_events, [\"$domain\"]); browser_domains = sort_by_duration(browser_domains); - browser_domains = limit_events(browser_domains, {}); browser_duration = sum_durations(browser_events); - ", - DEFAULT_LIMIT, DEFAULT_LIMIT - )); + ".to_string()); } else { query.push_str( "