diff --git a/Cargo.lock b/Cargo.lock index 5e9c5aade3..5fa76d1826 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -128,7 +128,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -975,6 +975,7 @@ version = "0.1.0" dependencies = [ "axum", "base64 0.22.1", + "buzz-collections", "buzz-core", "buzz-persona", "buzz-sdk", @@ -1000,6 +1001,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-collections" +version = "0.1.0" +dependencies = [ + "chrono", + "dirs", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "url", + "uuid", +] + [[package]] name = "buzz-conformance" version = "0.1.0" @@ -1717,7 +1733,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2338,7 +2354,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2549,7 +2565,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2772,7 +2788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2844,6 +2860,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -3188,7 +3216,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1", "windows-result 0.4.1", ] @@ -3412,6 +3440,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "hashlink" version = "0.11.0" @@ -4580,10 +4617,11 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" dependencies = [ + "cc", "pkg-config", "vcpkg", ] @@ -6033,7 +6071,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7506,7 +7544,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -8107,6 +8145,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.10.0", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -8177,7 +8229,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8190,7 +8242,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8249,7 +8301,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8532,7 +8584,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9029,7 +9081,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9127,7 +9179,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.16.1", - "hashlink", + "hashlink 0.11.0", "indexmap", "log", "memchr", @@ -9657,7 +9709,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9670,7 +9722,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10440,7 +10492,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11024,7 +11076,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8f5f949d74..c5871301e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "crates/buzz-deletion", "crates/buzz-workflow", "crates/buzz-media", + "crates/buzz-collections", "crates/buzz-cli", "crates/buzz-pairing-cli", "crates/buzz-sdk", @@ -143,6 +144,7 @@ buzz-search = { path = "crates/buzz-search" } buzz-audit = { path = "crates/buzz-audit" } buzz-workflow = { path = "crates/buzz-workflow" } buzz-media = { path = "crates/buzz-media" } +buzz-collections = { path = "crates/buzz-collections" } buzz-sdk = { path = "crates/buzz-sdk" } buzz-ws-client = { path = "crates/buzz-ws-client" } buzz-relay-mesh = { path = "crates/buzz-relay-mesh" } diff --git a/Justfile b/Justfile index 3cd0387453..c2c83b6ba5 100644 --- a/Justfile +++ b/Justfile @@ -656,6 +656,15 @@ desktop-dev: # ─── Web ───────────────────────────────────────────────────────────────────── +# Run the local Collections CLI against the same isolated profile selected by +# this checkout's Desktop dev launcher (for example: `just collections ls`). +collections *ARGS: + #!/usr/bin/env bash + set -euo pipefail + export PATH="{{justfile_directory()}}/bin:$PATH" + source scripts/collections-profile.sh + cargo run -q -p buzz-cli -- collections {{ARGS}} + # Run the web frontend dev server (port derived from worktree to avoid collisions) web: #!/usr/bin/env bash diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 1476e60bfd..75d07113ea 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -44,6 +44,7 @@ chrono = { workspace = true } # Typed event builders for all write operations buzz-sdk = { workspace = true } buzz-core = { workspace = true } +buzz-collections = { workspace = true } # Base64 encoding — NIP-98 event serialization for Authorization header base64 = "0.22" diff --git a/crates/buzz-cli/src/commands/collections.rs b/crates/buzz-cli/src/commands/collections.rs new file mode 100644 index 0000000000..5ab9fbef81 --- /dev/null +++ b/crates/buzz-cli/src/commands/collections.rs @@ -0,0 +1,330 @@ +use buzz_collections::{ + collections_db_path, CollectionReference, CollectionScope, CollectionsError, CollectionsStore, +}; +use clap::Subcommand; +use uuid::Uuid; + +use crate::{client::BuzzClient, error::CliError}; + +#[derive(Subcommand)] +#[command( + after_long_help = "The `get` JSON is the stable stored input for a Collection home: metadata plus ordered typed membership edges. Agents can compose those references with the existing channels, messages, repos, issues, and notes commands. Optional Desktop Calendar and GitHub activity is derived at read time through locally connected `sq` and `gh`; it is never persisted or imported into this OSS CLI." +)] +pub enum CollectionsCmd { + /// Create a local Collection + Create { + /// Human-readable Collection name + #[arg(long)] + name: String, + /// Optional Collection description + #[arg(long)] + description: Option, + /// Optional emoji or short icon text + #[arg(long)] + icon: Option, + }, + /// List local Collections for the current community and owner + #[command(name = "ls", visible_alias = "list")] + Ls, + /// Get one Collection's stored home input and ordered members + Get { + /// Collection UUID + #[arg(long)] + collection: String, + }, + /// Set a Collection's emoji or short icon text + SetIcon { + /// Collection UUID + #[arg(long)] + collection: String, + /// Emoji or short icon text + #[arg(long)] + icon: String, + }, + /// Clear a Collection's icon + ClearIcon { + /// Collection UUID + #[arg(long)] + collection: String, + }, + /// Rename a Collection + Rename { + /// Collection UUID + #[arg(long)] + collection: String, + /// New human-readable Collection name + #[arg(long)] + name: String, + }, + /// Delete a local Collection + Rm { + /// Collection UUID + #[arg(long)] + collection: String, + }, + /// Add an item to a Collection + Add { + /// Collection UUID + #[arg(long)] + collection: String, + #[command(subcommand)] + reference: CollectionReferenceCmd, + }, + /// Remove a membership edge from a Collection + Remove { + /// Collection UUID + #[arg(long)] + collection: String, + /// Membership UUID returned by get/add + #[arg(long)] + member: String, + }, +} + +#[derive(Subcommand)] +pub enum CollectionReferenceCmd { + /// Add a channel + Channel { + #[arg(long)] + channel: String, + #[arg(long)] + label: Option, + }, + /// Add a NIP-34 repository + Repository { + #[arg(long)] + repo: String, + #[arg(long)] + label: Option, + }, + /// Add a repository-linked task + Task { + #[arg(long)] + event: String, + #[arg(long)] + repo: String, + #[arg(long)] + label: Option, + }, + /// Add a channel thread + Thread { + #[arg(long)] + channel: String, + #[arg(long)] + event: String, + #[arg(long)] + label: Option, + }, + /// Add an individual message + Message { + #[arg(long)] + channel: String, + #[arg(long)] + event: String, + #[arg(long)] + label: Option, + }, + /// Add a NIP-23 note + Note { + /// NIP-33 coordinate (`kind:pubkey:identifier`) + #[arg(long)] + coordinate: String, + #[arg(long)] + label: Option, + }, + /// Add an external HTTP(S) URL + External { + #[arg(long)] + url: String, + #[arg(long)] + label: Option, + }, +} + +pub fn dispatch(command: CollectionsCmd, client: &BuzzClient) -> Result<(), CliError> { + let scope = scope_for_client(client)?; + let mut store = + CollectionsStore::open(collections_db_path().map_err(map_error)?).map_err(map_error)?; + + match command { + CollectionsCmd::Create { + name, + description, + icon, + } => print_json(&store.create_collection( + &scope, + &name, + description.as_deref(), + icon.as_deref(), + )?), + CollectionsCmd::Ls => print_json(&store.list_collections(&scope)?), + CollectionsCmd::Get { collection } => { + print_json(&store.get_collection(&scope, parse_uuid(&collection, "collection")?)?) + } + CollectionsCmd::SetIcon { collection, icon } => print_json(&store.set_collection_icon( + &scope, + parse_uuid(&collection, "collection")?, + Some(&icon), + )?), + CollectionsCmd::ClearIcon { collection } => print_json(&store.set_collection_icon( + &scope, + parse_uuid(&collection, "collection")?, + None, + )?), + CollectionsCmd::Rename { collection, name } => print_json(&store.set_collection_name( + &scope, + parse_uuid(&collection, "collection")?, + &name, + )?), + CollectionsCmd::Rm { collection } => { + let collection_id = parse_uuid(&collection, "collection")?; + store.delete_collection(&scope, collection_id)?; + print_json(&serde_json::json!({ + "deleted": true, + "collection_id": collection_id, + })) + } + CollectionsCmd::Add { + collection, + reference, + } => { + let collection_id = parse_uuid(&collection, "collection")?; + let (reference, label) = reference.into_reference()?; + print_json(&store.add_member(&scope, collection_id, &reference, label.as_deref())?) + } + CollectionsCmd::Remove { collection, member } => { + let collection_id = parse_uuid(&collection, "collection")?; + let member_id = parse_uuid(&member, "member")?; + store.remove_member(&scope, collection_id, member_id)?; + print_json(&serde_json::json!({ + "removed": true, + "collection_id": collection_id, + "member_id": member_id, + })) + } + } +} + +impl CollectionReferenceCmd { + fn into_reference(self) -> Result<(CollectionReference, Option), CliError> { + let (reference, label) = match self { + Self::Channel { channel, label } => (CollectionReference::channel(&channel), label), + Self::Repository { repo, label } => (CollectionReference::repository(&repo), label), + Self::Task { event, repo, label } => (CollectionReference::task(&event, &repo), label), + Self::Thread { + channel, + event, + label, + } => (CollectionReference::thread(&channel, &event), label), + Self::Message { + channel, + event, + label, + } => (CollectionReference::message(&channel, &event), label), + Self::Note { coordinate, label } => (CollectionReference::note(&coordinate), label), + Self::External { url, label } => (CollectionReference::external(&url), label), + }; + Ok((reference.map_err(map_error)?, label)) + } +} + +fn scope_for_client(client: &BuzzClient) -> Result { + let owner_pubkey = client + .auth_tag_owner_hex() + .unwrap_or_else(|| client.keys().public_key().to_hex()); + CollectionScope::new(client.relay_url(), &owner_pubkey).map_err(map_error) +} + +fn parse_uuid(value: &str, field: &str) -> Result { + Uuid::parse_str(value).map_err(|_| CliError::Usage(format!("invalid {field} UUID: {value}"))) +} + +fn print_json(value: &impl serde::Serialize) -> Result<(), CliError> { + println!( + "{}", + serde_json::to_string(value).map_err(|error| CliError::Other(format!( + "failed to serialize Collections output: {error}" + )))? + ); + Ok(()) +} + +fn map_error(error: CollectionsError) -> CliError { + match error { + CollectionsError::Validation(message) => CliError::Usage(message), + CollectionsError::NotFound(message) => CliError::NotFound(message), + other => CliError::Other(other.to_string()), + } +} + +impl From for CliError { + fn from(error: CollectionsError) -> Self { + map_error(error) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[derive(Parser)] + struct TestCli { + #[command(subcommand)] + command: CollectionsCmd, + } + + #[test] + fn parses_collection_ids_strictly() { + assert!(parse_uuid("not-a-uuid", "collection").is_err()); + assert!(parse_uuid("9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", "collection").is_ok()); + } + + #[test] + fn parses_create_and_icon_management_commands() { + let create = + TestCli::try_parse_from(["test", "create", "--name", "Bird Voice", "--icon", "🐦"]) + .expect("create command"); + assert!(matches!( + create.command, + CollectionsCmd::Create { + icon: Some(ref icon), + .. + } if icon == "🐦" + )); + + let set = TestCli::try_parse_from([ + "test", + "set-icon", + "--collection", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + "--icon", + "🎧", + ]) + .expect("set icon command"); + assert!(matches!(set.command, CollectionsCmd::SetIcon { .. })); + + let clear = TestCli::try_parse_from([ + "test", + "clear-icon", + "--collection", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + ]) + .expect("clear icon command"); + assert!(matches!(clear.command, CollectionsCmd::ClearIcon { .. })); + + let rename = TestCli::try_parse_from([ + "test", + "rename", + "--collection", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + "--name", + "Berd Voice", + ]) + .expect("rename command"); + assert!(matches!( + rename.command, + CollectionsCmd::Rename { ref name, .. } if name == "Berd Voice" + )); + } +} diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 8bb24218eb..d358fde4d1 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod agents; pub mod channel_templates; pub mod channels; +pub mod collections; pub mod dms; pub mod emoji; pub mod feed; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d0155970fa..3bee66a303 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -216,6 +216,9 @@ enum Cmd { /// Create and manage multi-repo projects (NIP-MP) #[command(subcommand)] Projects(ProjectsCmd), + /// Organize local Buzz content into Collections + #[command(subcommand)] + Collections(commands::collections::CollectionsCmd), /// Send, get, list, and set status on git patches (NIP-34) #[command(subcommand)] Patches(PatchesCmd), @@ -2088,6 +2091,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Notes(sub) => commands::notes::dispatch(sub, &client).await, Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await, Cmd::Projects(sub) => commands::projects::dispatch(sub, &client).await, + Cmd::Collections(sub) => commands::collections::dispatch(sub, &client), Cmd::Patches(sub) => commands::patches::dispatch(sub, &client).await, Cmd::Issues(sub) => commands::issues::dispatch(sub, &client).await, Cmd::Pr(sub) => commands::pr::dispatch(sub, &client).await, @@ -2226,6 +2230,7 @@ mod tests { "agents", "canvas", "channels", + "collections", "dms", "emoji", "feed", diff --git a/crates/buzz-collections/Cargo.toml b/crates/buzz-collections/Cargo.toml new file mode 100644 index 0000000000..234f0875ed --- /dev/null +++ b/crates/buzz-collections/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "buzz-collections" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Shared local Collections model and SQLite storage for Buzz" + +[dependencies] +chrono = { workspace = true } +dirs = "6" +rusqlite = { version = "0.37", features = ["bundled"] } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/buzz-collections/src/lib.rs b/crates/buzz-collections/src/lib.rs new file mode 100644 index 0000000000..3ff45b9f00 --- /dev/null +++ b/crates/buzz-collections/src/lib.rs @@ -0,0 +1,1144 @@ +//! Local-only Collections model and SQLite persistence shared by Buzz Desktop and `buzz-cli`. +//! +//! Collections are deliberately scoped to one relay community and owner identity. +//! The relay remains untouched: this crate is the prototype's single local source +//! of truth until the model is ready for a portable Nostr representation. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; +use serde::{Deserialize, Serialize}; +use url::Url; +use uuid::Uuid; + +const PROD_BUNDLE_IDENTIFIER: &str = "xyz.block.buzz.app"; +const PROFILE_DATA_DIR: &str = "xyz.block.buzz.collections"; +const DB_FILENAME: &str = "collections.sqlite3"; +const MAX_PROFILE_LEN: usize = 64; +const MAX_NAME_LEN: usize = 120; +const MAX_DESCRIPTION_LEN: usize = 4_000; +const MAX_ICON_LEN: usize = 32; +const MAX_LABEL_LEN: usize = 512; + +/// Errors returned by Collections validation and persistence. +#[derive(Debug, thiserror::Error)] +pub enum CollectionsError { + /// A caller supplied an invalid scope, collection field, or reference. + #[error("invalid collections input: {0}")] + Validation(String), + /// The requested collection or member does not exist in this scope. + #[error("{0}")] + NotFound(String), + /// The local platform data directory could not be resolved. + #[error("could not resolve the platform app-data directory")] + DataDirectoryUnavailable, + /// SQLite rejected a store operation. + #[error("collections database error: {0}")] + Database(#[from] rusqlite::Error), + /// A stored reference could not be decoded. + #[error("invalid collections data: {0}")] + Corrupt(String), + /// A reference could not be serialized. + #[error("collections serialization error: {0}")] + Serialization(#[from] serde_json::Error), + /// The store directory could not be created. + #[error("failed to prepare collections store: {0}")] + Io(#[from] std::io::Error), +} + +/// Community and owner boundary for local Collections state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CollectionScope { + /// Canonical WebSocket relay URL selecting the community. + pub relay_url: String, + /// Lowercase hex owner pubkey. Agents use their NIP-OA owner when present. + pub owner_pubkey: String, +} + +impl CollectionScope { + /// Validate and canonicalize a relay URL and owner pubkey. + pub fn new(relay_url: &str, owner_pubkey: &str) -> Result { + Ok(Self { + relay_url: normalize_relay_url(relay_url)?, + owner_pubkey: normalize_hex64(owner_pubkey, "owner pubkey")?, + }) + } +} + +/// A locally persisted Collection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Collection { + /// Stable local UUID. + pub id: Uuid, + /// Canonical relay URL selecting the community. + pub relay_url: String, + /// Owner identity for this Collection. + pub owner_pubkey: String, + /// Human-readable name, unique within the scope ignoring case. + pub name: String, + /// Optional human-readable description. + pub description: Option, + /// Optional emoji or short icon text chosen by the user. + pub icon: Option, + /// Creation time. + pub created_at: DateTime, + /// Last membership or metadata change time. + pub updated_at: DateTime, +} + +/// A validated reference to content grouped by a Collection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CollectionReference { + /// A NIP-29 channel UUID. + Channel { channel_id: Uuid }, + /// A NIP-34 repository coordinate (`30617::`). + Repository { coordinate: String }, + /// A repository-linked NIP-34 issue. + Task { + event_id: String, + repository: String, + }, + /// A channel thread identified by its root event. + Thread { + channel_id: Uuid, + root_event_id: String, + }, + /// An individual channel message. + Message { channel_id: Uuid, event_id: String }, + /// A NIP-23 long-form note coordinate (`30023::`). + Note { coordinate: String }, + /// An arbitrary HTTP(S) reference outside Buzz. + External { url: String }, +} + +impl CollectionReference { + /// Construct a channel reference. + pub fn channel(channel_id: &str) -> Result { + Ok(Self::Channel { + channel_id: parse_uuid(channel_id, "channel ID")?, + }) + } + + /// Construct a repository reference. + pub fn repository(coordinate: &str) -> Result { + Ok(Self::Repository { + coordinate: normalize_coordinate(coordinate, 30_617, "repository")?, + }) + } + + /// Construct a repository-linked task reference. + pub fn task(event_id: &str, repository: &str) -> Result { + Ok(Self::Task { + event_id: normalize_hex64(event_id, "task event ID")?, + repository: normalize_coordinate(repository, 30_617, "repository")?, + }) + } + + /// Construct a thread reference. + pub fn thread(channel_id: &str, root_event_id: &str) -> Result { + Ok(Self::Thread { + channel_id: parse_uuid(channel_id, "channel ID")?, + root_event_id: normalize_hex64(root_event_id, "thread root event ID")?, + }) + } + + /// Construct an individual message reference. + pub fn message(channel_id: &str, event_id: &str) -> Result { + Ok(Self::Message { + channel_id: parse_uuid(channel_id, "channel ID")?, + event_id: normalize_hex64(event_id, "message event ID")?, + }) + } + + /// Construct a NIP-23 long-form note reference. + pub fn note(coordinate: &str) -> Result { + Ok(Self::Note { + coordinate: normalize_coordinate(coordinate, 30_023, "note")?, + }) + } + + /// Construct an external HTTP(S) reference. + pub fn external(url: &str) -> Result { + Ok(Self::External { + url: normalize_external_url(url)?, + }) + } + + fn normalized(&self) -> Result { + match self { + Self::Channel { channel_id } => Self::channel(&channel_id.to_string()), + Self::Repository { coordinate } => Self::repository(coordinate), + Self::Task { + event_id, + repository, + } => Self::task(event_id, repository), + Self::Thread { + channel_id, + root_event_id, + } => Self::thread(&channel_id.to_string(), root_event_id), + Self::Message { + channel_id, + event_id, + } => Self::message(&channel_id.to_string(), event_id), + Self::Note { coordinate } => Self::note(coordinate), + Self::External { url } => Self::external(url), + } + } + + fn kind_and_key(&self) -> Result<(&'static str, String), CollectionsError> { + let normalized = self.normalized()?; + Ok(match normalized { + Self::Channel { channel_id } => ("channel", channel_id.to_string()), + Self::Repository { coordinate } => ("repository", coordinate), + Self::Task { + event_id, + repository, + } => ("task", format!("{repository}|{event_id}")), + Self::Thread { + channel_id, + root_event_id, + } => ("thread", format!("{channel_id}|{root_event_id}")), + Self::Message { + channel_id, + event_id, + } => ("message", format!("{channel_id}|{event_id}")), + Self::Note { coordinate } => ("note", coordinate), + Self::External { url } => ("external", url), + }) + } +} + +/// One Collection membership edge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CollectionMember { + /// Stable UUID for removing this membership. + pub id: Uuid, + /// Parent Collection UUID. + pub collection_id: Uuid, + /// Canonical typed content reference. + pub reference: CollectionReference, + /// Optional cached display label; never used as identity. + pub label: Option, + /// Time this membership was created. + pub added_at: DateTime, +} + +/// A Collection and its ordered membership list. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CollectionWithMembers { + /// Collection metadata. + pub collection: Collection, + /// Membership edges, newest first. + pub members: Vec, +} + +/// Resolve the shared Collections database path. +/// +/// `BUZZ_COLLECTIONS_DB` overrides the default for isolated tests and explicit +/// production-data prototypes. `BUZZ_COLLECTIONS_PROFILE` selects an isolated, +/// named local profile shared with Desktop. Release builds without either +/// override use the installed app's production data path. +pub fn collections_db_path() -> Result { + if let Some(path) = collections_db_override() { + return Ok(path); + } + let data_dir = dirs::data_dir().ok_or(CollectionsError::DataDirectoryUnavailable)?; + if let Some(profile) = collections_profile()? { + return Ok(profile_db_path(&data_dir, &profile)); + } + if cfg!(debug_assertions) { + return Err(CollectionsError::Validation( + "debug buzz collections requires BUZZ_COLLECTIONS_PROFILE; use `just collections ...`" + .into(), + )); + } + Ok(data_dir.join(PROD_BUNDLE_IDENTIFIER).join(DB_FILENAME)) +} + +/// Resolve the Collections database relative to a Tauri app-data directory. +/// +/// Explicit database overrides remain authoritative. A named profile resolves +/// beside application data, never inside or by copying the installed app's +/// production directory. Without a profile, Desktop keeps its bundle-specific +/// app-data isolation. +pub fn collections_db_path_for_app_data_dir( + app_data_dir: &Path, +) -> Result { + if let Some(path) = collections_db_override() { + return Ok(path); + } + if let Some(profile) = collections_profile()? { + let data_dir = app_data_dir + .parent() + .ok_or(CollectionsError::DataDirectoryUnavailable)?; + return Ok(profile_db_path(data_dir, &profile)); + } + Ok(app_data_dir.join(DB_FILENAME)) +} + +fn collections_db_override() -> Option { + std::env::var_os("BUZZ_COLLECTIONS_DB") + .filter(|path| !path.is_empty()) + .map(PathBuf::from) +} + +fn collections_profile() -> Result, CollectionsError> { + std::env::var("BUZZ_COLLECTIONS_PROFILE") + .ok() + .filter(|profile| !profile.is_empty()) + .map(|profile| normalize_profile(&profile)) + .transpose() +} + +fn normalize_profile(raw: &str) -> Result { + if raw != raw.trim() + || raw.is_empty() + || raw.len() > MAX_PROFILE_LEN + || !raw.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte) + }) + || !raw + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + { + return Err(CollectionsError::Validation( + "collections profile must be 1-64 lowercase letters, digits, dots, underscores, or hyphens and start with a letter or digit" + .into(), + )); + } + Ok(raw.to_string()) +} + +fn profile_db_path(data_dir: &Path, profile: &str) -> PathBuf { + data_dir + .join(PROFILE_DATA_DIR) + .join(profile) + .join(DB_FILENAME) +} + +/// Synchronous SQLite store. Callers from async runtimes should use a blocking task. +pub struct CollectionsStore { + connection: Connection, +} + +impl CollectionsStore { + /// Open or create the Collections database at `path`. + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent)?; + } + let connection = Connection::open(path)?; + Self::initialize(connection) + } + + /// Open an in-memory Collections database, primarily for tests. + pub fn in_memory() -> Result { + Self::initialize(Connection::open_in_memory()?) + } + + fn initialize(mut connection: Connection) -> Result { + connection.busy_timeout(Duration::from_secs(5))?; + connection.execute_batch( + "PRAGMA foreign_keys = ON; + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS collections ( + id TEXT PRIMARY KEY NOT NULL, + relay_url TEXT NOT NULL, + owner_pubkey TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + icon TEXT, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS collections_scope_name + ON collections(relay_url, owner_pubkey, name COLLATE NOCASE); + CREATE INDEX IF NOT EXISTS collections_scope_updated + ON collections(relay_url, owner_pubkey, updated_at_ms DESC); + CREATE TABLE IF NOT EXISTS collection_members ( + id TEXT PRIMARY KEY NOT NULL, + collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + reference_kind TEXT NOT NULL, + reference_key TEXT NOT NULL, + reference_json TEXT NOT NULL, + label TEXT, + added_at_ms INTEGER NOT NULL, + UNIQUE(collection_id, reference_kind, reference_key) + ); + CREATE INDEX IF NOT EXISTS collection_members_collection + ON collection_members(collection_id, added_at_ms DESC);", + )?; + let migration = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + ensure_icon_column(&migration)?; + migration.commit()?; + Ok(Self { connection }) + } + + /// Create a Collection in `scope`. + pub fn create_collection( + &mut self, + scope: &CollectionScope, + name: &str, + description: Option<&str>, + icon: Option<&str>, + ) -> Result { + let name = normalize_required_text(name, "collection name", MAX_NAME_LEN)?; + let description = normalize_optional_text(description, "description", MAX_DESCRIPTION_LEN)?; + let icon = normalize_optional_text(icon, "collection icon", MAX_ICON_LEN)?; + let now = Utc::now(); + let collection = Collection { + id: Uuid::new_v4(), + relay_url: scope.relay_url.clone(), + owner_pubkey: scope.owner_pubkey.clone(), + name, + description, + icon, + created_at: now, + updated_at: now, + }; + let result = self.connection.execute( + "INSERT INTO collections + (id, relay_url, owner_pubkey, name, description, icon, created_at_ms, updated_at_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + collection.id.to_string(), + collection.relay_url, + collection.owner_pubkey, + collection.name, + collection.description, + collection.icon, + collection.created_at.timestamp_millis(), + collection.updated_at.timestamp_millis(), + ], + ); + match result { + Ok(_) => Ok(collection), + Err(error) if is_unique_violation(&error) => Err(CollectionsError::Validation( + "a collection with that name already exists in this community".into(), + )), + Err(error) => Err(error.into()), + } + } + + /// List Collections in a scope, most recently changed first. + pub fn list_collections( + &self, + scope: &CollectionScope, + ) -> Result, CollectionsError> { + let mut statement = self.connection.prepare( + "SELECT id, relay_url, owner_pubkey, name, description, icon, created_at_ms, updated_at_ms + FROM collections WHERE relay_url = ?1 AND owner_pubkey = ?2 + ORDER BY updated_at_ms DESC, name COLLATE NOCASE ASC", + )?; + let rows = statement.query_map( + params![scope.relay_url, scope.owner_pubkey], + read_collection_row, + )?; + rows.collect::, _>>().map_err(Into::into) + } + + /// Read one Collection and all memberships in the same scope. + pub fn get_collection( + &self, + scope: &CollectionScope, + collection_id: Uuid, + ) -> Result { + let collection = self + .connection + .query_row( + "SELECT id, relay_url, owner_pubkey, name, description, icon, created_at_ms, updated_at_ms + FROM collections WHERE id = ?1 AND relay_url = ?2 AND owner_pubkey = ?3", + params![collection_id.to_string(), scope.relay_url, scope.owner_pubkey], + read_collection_row, + ) + .optional()? + .ok_or_else(|| CollectionsError::NotFound(format!("collection {collection_id} not found")))?; + let mut statement = self.connection.prepare( + "SELECT id, collection_id, reference_json, label, added_at_ms + FROM collection_members WHERE collection_id = ?1 + ORDER BY added_at_ms DESC, id ASC", + )?; + let raw = statement.query_map(params![collection_id.to_string()], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, i64>(4)?, + )) + })?; + let members = raw + .collect::, _>>()? + .into_iter() + .map(decode_member) + .collect::, _>>()?; + Ok(CollectionWithMembers { + collection, + members, + }) + } + + /// Set or clear the optional icon for a Collection in this scope. + pub fn set_collection_icon( + &mut self, + scope: &CollectionScope, + collection_id: Uuid, + icon: Option<&str>, + ) -> Result { + let icon = icon + .map(|value| normalize_required_text(value, "collection icon", MAX_ICON_LEN)) + .transpose()?; + let now = Utc::now(); + let changed = self.connection.execute( + "UPDATE collections SET icon = ?1, updated_at_ms = ?2 + WHERE id = ?3 AND relay_url = ?4 AND owner_pubkey = ?5", + params![ + icon, + now.timestamp_millis(), + collection_id.to_string(), + scope.relay_url, + scope.owner_pubkey, + ], + )?; + if changed == 0 { + return Err(CollectionsError::NotFound(format!( + "collection {collection_id} not found" + ))); + } + self.get_collection(scope, collection_id) + .map(|collection| collection.collection) + } + + /// Rename a Collection in this scope without changing its memberships. + pub fn set_collection_name( + &mut self, + scope: &CollectionScope, + collection_id: Uuid, + name: &str, + ) -> Result { + let name = normalize_required_text(name, "collection name", MAX_NAME_LEN)?; + let now = Utc::now(); + let result = self.connection.execute( + "UPDATE collections SET name = ?1, updated_at_ms = ?2 + WHERE id = ?3 AND relay_url = ?4 AND owner_pubkey = ?5", + params![ + name, + now.timestamp_millis(), + collection_id.to_string(), + scope.relay_url, + scope.owner_pubkey, + ], + ); + match result { + Ok(0) => Err(CollectionsError::NotFound(format!( + "collection {collection_id} not found" + ))), + Ok(_) => self + .get_collection(scope, collection_id) + .map(|collection| collection.collection), + Err(error) if is_unique_violation(&error) => Err(CollectionsError::Validation( + "a collection with that name already exists in this community".into(), + )), + Err(error) => Err(error.into()), + } + } + + /// Delete a Collection and its membership edges from this scope. + pub fn delete_collection( + &mut self, + scope: &CollectionScope, + collection_id: Uuid, + ) -> Result<(), CollectionsError> { + let changed = self.connection.execute( + "DELETE FROM collections WHERE id = ?1 AND relay_url = ?2 AND owner_pubkey = ?3", + params![ + collection_id.to_string(), + scope.relay_url, + scope.owner_pubkey + ], + )?; + if changed == 0 { + return Err(CollectionsError::NotFound(format!( + "collection {collection_id} not found" + ))); + } + Ok(()) + } + + /// Add a membership edge. Repeating the same add returns the existing edge. + pub fn add_member( + &mut self, + scope: &CollectionScope, + collection_id: Uuid, + reference: &CollectionReference, + label: Option<&str>, + ) -> Result { + let reference = reference.normalized()?; + let (reference_kind, reference_key) = reference.kind_and_key()?; + let label = normalize_optional_text(label, "member label", MAX_LABEL_LEN)?; + let reference_json = serde_json::to_string(&reference)?; + let now = Utc::now(); + let member_id = Uuid::new_v4(); + let transaction = self.connection.transaction()?; + let collection_exists = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM collections WHERE id = ?1 AND relay_url = ?2 AND owner_pubkey = ?3 + )", + params![ + collection_id.to_string(), + scope.relay_url, + scope.owner_pubkey + ], + |row| row.get::<_, bool>(0), + )?; + if !collection_exists { + return Err(CollectionsError::NotFound(format!( + "collection {collection_id} not found" + ))); + } + let inserted = transaction.execute( + "INSERT OR IGNORE INTO collection_members + (id, collection_id, reference_kind, reference_key, reference_json, label, added_at_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + member_id.to_string(), + collection_id.to_string(), + reference_kind, + reference_key, + reference_json, + label, + now.timestamp_millis(), + ], + )?; + if inserted > 0 { + transaction.execute( + "UPDATE collections SET updated_at_ms = ?1 WHERE id = ?2", + params![now.timestamp_millis(), collection_id.to_string()], + )?; + } + let raw = transaction.query_row( + "SELECT id, collection_id, reference_json, label, added_at_ms + FROM collection_members + WHERE collection_id = ?1 AND reference_kind = ?2 AND reference_key = ?3", + params![collection_id.to_string(), reference_kind, reference_key], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, i64>(4)?, + )) + }, + )?; + transaction.commit()?; + decode_member(raw) + } + + /// Remove one membership edge from a Collection in this scope. + pub fn remove_member( + &mut self, + scope: &CollectionScope, + collection_id: Uuid, + member_id: Uuid, + ) -> Result<(), CollectionsError> { + let transaction = self.connection.transaction()?; + let changed = transaction.execute( + "DELETE FROM collection_members + WHERE id = ?1 AND collection_id = ?2 AND EXISTS( + SELECT 1 FROM collections + WHERE id = ?2 AND relay_url = ?3 AND owner_pubkey = ?4 + )", + params![ + member_id.to_string(), + collection_id.to_string(), + scope.relay_url, + scope.owner_pubkey, + ], + )?; + if changed == 0 { + return Err(CollectionsError::NotFound(format!( + "member {member_id} not found in collection {collection_id}" + ))); + } + transaction.execute( + "UPDATE collections SET updated_at_ms = ?1 WHERE id = ?2", + params![Utc::now().timestamp_millis(), collection_id.to_string()], + )?; + transaction.commit()?; + Ok(()) + } +} + +fn normalize_relay_url(raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + return Err(CollectionsError::Validation("relay URL is required".into())); + } + let mut parsed = Url::parse(raw) + .map_err(|error| CollectionsError::Validation(format!("invalid relay URL: {error}")))?; + let canonical_scheme = match parsed.scheme() { + "ws" | "http" => "ws", + "wss" | "https" => "wss", + scheme => { + return Err(CollectionsError::Validation(format!( + "relay URL scheme must be ws, wss, http, or https (got {scheme})" + ))) + } + }; + parsed + .set_scheme(canonical_scheme) + .map_err(|_| CollectionsError::Validation("invalid relay URL scheme".into()))?; + if parsed.host_str().is_none() || !parsed.username().is_empty() || parsed.password().is_some() { + return Err(CollectionsError::Validation( + "relay URL must have a host and cannot contain credentials".into(), + )); + } + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(CollectionsError::Validation( + "relay URL cannot contain a query or fragment".into(), + )); + } + let path = parsed.path().trim_end_matches('/').to_string(); + parsed.set_path(&path); + Ok(parsed.as_str().trim_end_matches('/').to_string()) +} + +fn normalize_external_url(raw: &str) -> Result { + let mut parsed = Url::parse(raw.trim()) + .map_err(|error| CollectionsError::Validation(format!("invalid external URL: {error}")))?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { + return Err(CollectionsError::Validation( + "external URL must be an absolute http or https URL".into(), + )); + } + parsed.set_fragment(None); + Ok(parsed.to_string()) +} + +fn normalize_coordinate( + raw: &str, + expected_kind: u32, + label: &str, +) -> Result { + let mut parts = raw.trim().splitn(3, ':'); + let kind = parts.next().unwrap_or_default(); + let pubkey = parts.next().unwrap_or_default(); + let identifier = parts.next().unwrap_or_default(); + if kind.parse::().ok() != Some(expected_kind) { + return Err(CollectionsError::Validation(format!( + "{label} coordinate must start with {expected_kind}:" + ))); + } + let pubkey = normalize_hex64(pubkey, &format!("{label} owner pubkey"))?; + if identifier.is_empty() || identifier.chars().any(char::is_control) { + return Err(CollectionsError::Validation(format!( + "{label} coordinate requires a non-empty d-tag" + ))); + } + Ok(format!("{expected_kind}:{pubkey}:{identifier}")) +} + +fn normalize_hex64(raw: &str, label: &str) -> Result { + let raw = raw.trim(); + if raw.len() != 64 || !raw.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(CollectionsError::Validation(format!( + "{label} must be 64 hexadecimal characters" + ))); + } + Ok(raw.to_ascii_lowercase()) +} + +fn parse_uuid(raw: &str, label: &str) -> Result { + Uuid::parse_str(raw.trim()) + .map_err(|_| CollectionsError::Validation(format!("{label} must be a UUID"))) +} + +fn normalize_required_text( + raw: &str, + label: &str, + max_len: usize, +) -> Result { + let value = raw.trim(); + if value.is_empty() { + return Err(CollectionsError::Validation(format!("{label} is required"))); + } + if value.chars().count() > max_len || value.chars().any(char::is_control) { + return Err(CollectionsError::Validation(format!( + "{label} must be at most {max_len} characters and contain no control characters" + ))); + } + Ok(value.to_string()) +} + +fn normalize_optional_text( + raw: Option<&str>, + label: &str, + max_len: usize, +) -> Result, CollectionsError> { + raw.map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| normalize_required_text(value, label, max_len)) + .transpose() +} + +fn ensure_icon_column(connection: &Connection) -> Result<(), CollectionsError> { + let mut statement = connection.prepare("PRAGMA table_info(collections)")?; + let columns = statement + .query_map([], |row| row.get::<_, String>(1))? + .collect::, _>>()?; + if !columns.iter().any(|column| column == "icon") { + connection.execute("ALTER TABLE collections ADD COLUMN icon TEXT", [])?; + } + Ok(()) +} + +fn read_collection_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let id = row.get::<_, String>(0)?; + let created_at_ms = row.get::<_, i64>(6)?; + let updated_at_ms = row.get::<_, i64>(7)?; + Ok(Collection { + id: Uuid::parse_str(&id).map_err(|error| conversion_error(0, error))?, + relay_url: row.get(1)?, + owner_pubkey: row.get(2)?, + name: row.get(3)?, + description: row.get(4)?, + icon: row.get(5)?, + created_at: timestamp(created_at_ms).map_err(|error| conversion_error(6, error))?, + updated_at: timestamp(updated_at_ms).map_err(|error| conversion_error(7, error))?, + }) +} + +fn decode_member( + raw: (String, String, String, Option, i64), +) -> Result { + let (id, collection_id, reference_json, label, added_at_ms) = raw; + Ok(CollectionMember { + id: Uuid::parse_str(&id) + .map_err(|error| CollectionsError::Corrupt(format!("invalid member UUID: {error}")))?, + collection_id: Uuid::parse_str(&collection_id).map_err(|error| { + CollectionsError::Corrupt(format!("invalid collection UUID: {error}")) + })?, + reference: serde_json::from_str(&reference_json)?, + label, + added_at: timestamp(added_at_ms)?, + }) +} + +fn timestamp(milliseconds: i64) -> Result, CollectionsError> { + DateTime::from_timestamp_millis(milliseconds).ok_or_else(|| { + CollectionsError::Corrupt(format!("invalid timestamp milliseconds: {milliseconds}")) + }) +} + +fn conversion_error( + column: usize, + error: impl std::error::Error + Send + Sync + 'static, +) -> rusqlite::Error { + rusqlite::Error::FromSqlConversionFailure(column, rusqlite::types::Type::Text, Box::new(error)) +} + +fn is_unique_violation(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure(inner, _) + if inner.code == rusqlite::ErrorCode::ConstraintViolation + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const OWNER_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OWNER_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const EVENT: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + const CHANNEL: &str = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + + fn scope(relay: &str, owner: &str) -> CollectionScope { + CollectionScope::new(relay, owner).expect("scope") + } + + #[test] + fn scope_canonicalizes_transport_and_identity() { + let scope = CollectionScope::new("https://BUZZ.Example/", &OWNER_A.to_uppercase()) + .expect("valid scope"); + assert_eq!(scope.relay_url, "wss://buzz.example"); + assert_eq!(scope.owner_pubkey, OWNER_A); + } + + #[test] + fn blank_optional_text_is_absent() { + assert_eq!( + normalize_optional_text(Some(" "), "description", MAX_DESCRIPTION_LEN) + .expect("normalize"), + None + ); + } + + #[test] + fn named_profiles_are_validated_and_isolated_from_app_data() { + assert_eq!(normalize_profile("dev.main").expect("profile"), "dev.main"); + assert_eq!( + profile_db_path(Path::new("/data"), "dev.feature-one"), + Path::new("/data") + .join(PROFILE_DATA_DIR) + .join("dev.feature-one") + .join(DB_FILENAME) + ); + for invalid in [ + "", + " Dev", + "dev/../../production", + "UPPERCASE", + ".hidden", + &"x".repeat(MAX_PROFILE_LEN + 1), + ] { + assert!(normalize_profile(invalid).is_err(), "accepted {invalid}"); + } + } + + #[test] + fn references_validate_and_normalize_stable_identity() { + assert_eq!( + CollectionReference::repository(&format!("30617:{}:Buzz", OWNER_A.to_uppercase())) + .expect("repo"), + CollectionReference::Repository { + coordinate: format!("30617:{OWNER_A}:Buzz") + } + ); + assert!(CollectionReference::task(EVENT, "30023:bad:note").is_err()); + assert!(CollectionReference::external("javascript:alert(1)").is_err()); + } + + #[test] + fn crud_is_scoped_by_community_and_owner() { + let mut store = CollectionsStore::in_memory().expect("store"); + let a = scope("wss://one.example", OWNER_A); + let b = scope("wss://one.example", OWNER_B); + let c = scope("wss://two.example", OWNER_A); + let collection = store + .create_collection(&a, "Bird Voice", Some("Voice work"), Some(" 🐦 ")) + .expect("create"); + + assert_eq!(collection.icon.as_deref(), Some("🐦")); + + assert_eq!(store.list_collections(&a).expect("list").len(), 1); + assert!(store.list_collections(&b).expect("list").is_empty()); + assert!(store.list_collections(&c).expect("list").is_empty()); + assert!(matches!( + store.get_collection(&b, collection.id), + Err(CollectionsError::NotFound(_)) + )); + } + + #[test] + fn icon_can_be_set_changed_and_cleared() { + let mut store = CollectionsStore::in_memory().expect("store"); + let scope = scope("wss://buzz.example", OWNER_A); + let collection = store + .create_collection(&scope, "Bird Voice", None, None) + .expect("create"); + + let updated = store + .set_collection_icon(&scope, collection.id, Some(" 🐦️ ")) + .expect("set icon"); + assert_eq!(updated.icon.as_deref(), Some("🐦️")); + + let cleared = store + .set_collection_icon(&scope, collection.id, None) + .expect("clear icon"); + assert_eq!(cleared.icon, None); + assert!(store + .set_collection_icon(&scope, collection.id, Some("bad\nicon")) + .is_err()); + assert!(store + .set_collection_icon(&scope, collection.id, Some(" ")) + .is_err()); + assert!(store + .set_collection_icon(&scope, collection.id, Some(&"x".repeat(MAX_ICON_LEN + 1))) + .is_err()); + } + + #[test] + fn rename_preserves_identity_and_memberships() { + let mut store = CollectionsStore::in_memory().expect("store"); + let scope = scope("wss://buzz.example", OWNER_A); + let collection = store + .create_collection(&scope, "Bird Voice", None, Some("🐦")) + .expect("create"); + let member = store + .add_member( + &scope, + collection.id, + &CollectionReference::channel(CHANNEL).expect("channel"), + None, + ) + .expect("member"); + + let renamed = store + .set_collection_name(&scope, collection.id, " Berd Voice ") + .expect("rename"); + assert_eq!(renamed.id, collection.id); + assert_eq!(renamed.name, "Berd Voice"); + assert_eq!(renamed.icon, collection.icon); + let reloaded = store + .get_collection(&scope, collection.id) + .expect("reloaded"); + assert_eq!(reloaded.members, vec![member]); + assert!(store + .set_collection_name(&scope, collection.id, "bad\nname") + .is_err()); + } + + #[test] + fn opening_legacy_database_adds_nullable_icon_column() { + let directory = tempfile::tempdir().expect("tempdir"); + let path = directory.path().join("legacy.sqlite3"); + let connection = Connection::open(&path).expect("legacy connection"); + connection + .execute_batch( + "CREATE TABLE collections ( + id TEXT PRIMARY KEY NOT NULL, + relay_url TEXT NOT NULL, + owner_pubkey TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + );", + ) + .expect("legacy schema"); + let legacy_id = Uuid::new_v4(); + connection + .execute( + "INSERT INTO collections + (id, relay_url, owner_pubkey, name, description, created_at_ms, updated_at_ms) + VALUES (?1, ?2, ?3, ?4, NULL, 1, 1)", + params![ + legacy_id.to_string(), + "wss://buzz.example", + OWNER_A, + "Legacy" + ], + ) + .expect("legacy row"); + drop(connection); + + let store = CollectionsStore::open(&path).expect("migrated store"); + let legacy = store + .get_collection(&scope("wss://buzz.example", OWNER_A), legacy_id) + .expect("preserved legacy collection") + .collection; + assert_eq!(legacy.name, "Legacy"); + assert_eq!(legacy.icon, None); + let columns = store + .connection + .prepare("PRAGMA table_info(collections)") + .expect("prepare") + .query_map([], |row| row.get::<_, String>(1)) + .expect("columns") + .collect::, _>>() + .expect("collect columns"); + assert!(columns.iter().any(|column| column == "icon")); + } + + #[test] + fn membership_is_idempotent_and_many_to_many() { + let mut store = CollectionsStore::in_memory().expect("store"); + let scope = scope("wss://buzz.example", OWNER_A); + let first = store + .create_collection(&scope, "First", None, None) + .expect("first"); + let second = store + .create_collection(&scope, "Second", None, None) + .expect("second"); + let reference = CollectionReference::message(CHANNEL, EVENT).expect("message"); + + let edge = store + .add_member(&scope, first.id, &reference, Some("Decision")) + .expect("add"); + let repeated = store + .add_member(&scope, first.id, &reference, Some("Ignored replacement")) + .expect("repeat"); + let other = store + .add_member(&scope, second.id, &reference, None) + .expect("other collection"); + + assert_eq!(edge.id, repeated.id); + assert_eq!(repeated.label.as_deref(), Some("Decision")); + assert_ne!(edge.id, other.id); + assert_eq!( + store + .get_collection(&scope, first.id) + .expect("get") + .members + .len(), + 1 + ); + assert_eq!( + store + .get_collection(&scope, second.id) + .expect("get") + .members + .len(), + 1 + ); + } + + #[test] + fn delete_cascades_memberships() { + let mut store = CollectionsStore::in_memory().expect("store"); + let scope = scope("wss://buzz.example", OWNER_A); + let collection = store + .create_collection(&scope, "Temporary", None, None) + .expect("create"); + let member = store + .add_member( + &scope, + collection.id, + &CollectionReference::channel(CHANNEL).expect("channel"), + None, + ) + .expect("member"); + store + .delete_collection(&scope, collection.id) + .expect("delete"); + + let count: i64 = store + .connection + .query_row( + "SELECT COUNT(*) FROM collection_members WHERE id = ?1", + params![member.id.to_string()], + |row| row.get(0), + ) + .expect("count"); + assert_eq!(count, 0); + } + + #[test] + fn file_store_supports_multiple_writers() { + let directory = tempfile::tempdir().expect("tempdir"); + let path = directory.path().join("collections.sqlite3"); + let scope = scope("wss://buzz.example", OWNER_A); + let mut first = CollectionsStore::open(&path).expect("first store"); + let mut second = CollectionsStore::open(&path).expect("second store"); + + first + .create_collection(&scope, "One", None, None) + .expect("first write"); + second + .create_collection(&scope, "Two", None, None) + .expect("second write"); + + assert_eq!(first.list_collections(&scope).expect("list").len(), 2); + } +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 4c2b6cd7fc..ed912e0f50 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/collections.spec.ts", "**/thread-head-stale-edit.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", "**/tooltip-semantics.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 68b702431a..da963fe9fc 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1058,6 +1058,20 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "buzz-collections" +version = "0.1.0" +dependencies = [ + "chrono", + "dirs", + "rusqlite", + "serde", + "serde_json", + "thiserror 2.0.18", + "url", + "uuid", +] + [[package]] name = "buzz-core" version = "0.1.0" @@ -1091,6 +1105,7 @@ dependencies = [ "base64 0.22.1", "block2", "buzz-agent", + "buzz-collections", "buzz-core", "buzz-media", "buzz-persona", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 2255a41860..8873dccbe4 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -103,6 +103,7 @@ reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] } url = "2" buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" } +buzz_collections_pkg = { package = "buzz-collections", path = "../../crates/buzz-collections" } buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" } buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" } buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" } diff --git a/desktop/src-tauri/src/commands/collections.rs b/desktop/src-tauri/src/commands/collections.rs new file mode 100644 index 0000000000..291e210fb7 --- /dev/null +++ b/desktop/src-tauri/src/commands/collections.rs @@ -0,0 +1,1411 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + process::Stdio, + time::Duration, +}; + +use buzz_collections_pkg::{ + collections_db_path_for_app_data_dir, Collection, CollectionMember, CollectionReference, + CollectionScope, CollectionWithMembers, CollectionsError, CollectionsStore, +}; +use chrono::{DateTime, NaiveDateTime, SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tauri::{AppHandle, Manager}; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::process::Command; +use uuid::Uuid; + +const CALENDAR_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(20); +const CALENDAR_TOOL_TIMEOUT_SECS: &str = "15"; +const DRIVE_PROCESS_TIMEOUT: Duration = Duration::from_secs(12); +const DRIVE_TOOL_TIMEOUT_SECS: &str = "10"; +const CALENDAR_ACTIVITY_TIMEOUT: Duration = Duration::from_secs(45); +const MAX_ACTIVITY_DOCUMENTS: usize = 16; +const MAX_CALENDAR_STDOUT_BYTES: usize = 1024 * 1024; +const MAX_CALENDAR_STDERR_BYTES: usize = 64 * 1024; +const CALENDAR_UNAVAILABLE: &str = "Optional Google Calendar discovery is unavailable"; + +#[derive(Debug, Deserialize)] +pub struct CreateCollectionInput { + pub relay_url: String, + pub owner_pubkey: String, + pub name: String, + pub description: Option, + pub icon: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SetCollectionIconInput { + pub relay_url: String, + pub owner_pubkey: String, + pub collection_id: Uuid, + pub icon: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SetCollectionNameInput { + pub relay_url: String, + pub owner_pubkey: String, + pub collection_id: Uuid, + pub name: String, +} + +#[derive(Debug, Deserialize)] +pub struct AddCollectionMemberInput { + pub relay_url: String, + pub owner_pubkey: String, + pub collection_id: Uuid, + pub reference: CollectionReference, + pub label: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CollectionDiscoveredLink { + pub url: String, + pub label: String, + pub kind: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CollectionCalendarActivity { + pub action_type: String, + pub timestamp: String, + pub actor_display_name: Option, + pub actor_email: Option, + pub document_title: String, + pub document_url: String, + pub document_file_id: String, + pub source_calendar_url: String, + pub source_attachment_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CollectionActivitySourceError { + pub source_url: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CollectionCalendarActivityResult { + pub activities: Vec, + pub errors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DriveDocumentMetadata { + file_id: String, + title: String, + url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedDriveActivity { + action_type: String, + timestamp: String, + actor_display_name: Option, + actor_email: Option, +} + +#[derive(Debug, Deserialize)] +struct CalendarToolResponse { + #[serde(default)] + result: Option, + #[serde(default)] + attachments: Vec, +} + +#[derive(Debug, Deserialize)] +struct CalendarEventResult { + #[serde(default)] + attachments: Vec, +} + +#[derive(Debug, Deserialize)] +struct CalendarAttachment { + #[serde(rename = "fileUrl")] + file_url: String, + #[serde(default)] + title: String, +} + +#[tauri::command] +pub async fn list_collections( + relay_url: String, + owner_pubkey: String, + app: AppHandle, +) -> Result, String> { + let path = database_path(&app)?; + let scope = CollectionScope::new(&relay_url, &owner_pubkey).map_err(render_error)?; + with_store(path, move |store| store.list_collections(&scope)).await +} + +#[tauri::command] +pub async fn get_collection( + relay_url: String, + owner_pubkey: String, + id: String, + app: AppHandle, +) -> Result { + let path = database_path(&app)?; + let scope = CollectionScope::new(&relay_url, &owner_pubkey).map_err(render_error)?; + let id = parse_uuid(&id, "collection")?; + with_store(path, move |store| store.get_collection(&scope, id)).await +} + +#[tauri::command] +pub async fn create_collection( + input: CreateCollectionInput, + app: AppHandle, +) -> Result { + let path = database_path(&app)?; + let scope = + CollectionScope::new(&input.relay_url, &input.owner_pubkey).map_err(render_error)?; + with_store(path, move |store| { + store.create_collection( + &scope, + &input.name, + input.description.as_deref(), + input.icon.as_deref(), + ) + }) + .await +} + +#[tauri::command] +pub async fn set_collection_icon( + input: SetCollectionIconInput, + app: AppHandle, +) -> Result { + let path = database_path(&app)?; + let scope = + CollectionScope::new(&input.relay_url, &input.owner_pubkey).map_err(render_error)?; + with_store(path, move |store| { + store.set_collection_icon(&scope, input.collection_id, input.icon.as_deref()) + }) + .await +} + +#[tauri::command] +pub async fn set_collection_name( + input: SetCollectionNameInput, + app: AppHandle, +) -> Result { + let path = database_path(&app)?; + let scope = + CollectionScope::new(&input.relay_url, &input.owner_pubkey).map_err(render_error)?; + with_store(path, move |store| { + store.set_collection_name(&scope, input.collection_id, &input.name) + }) + .await +} + +#[tauri::command] +pub async fn delete_collection( + relay_url: String, + owner_pubkey: String, + id: String, + app: AppHandle, +) -> Result<(), String> { + let path = database_path(&app)?; + let scope = CollectionScope::new(&relay_url, &owner_pubkey).map_err(render_error)?; + let id = parse_uuid(&id, "collection")?; + with_store(path, move |store| store.delete_collection(&scope, id)).await +} + +#[tauri::command] +pub async fn add_collection_member( + input: AddCollectionMemberInput, + app: AppHandle, +) -> Result { + let path = database_path(&app)?; + let scope = + CollectionScope::new(&input.relay_url, &input.owner_pubkey).map_err(render_error)?; + with_store(path, move |store| { + store.add_member( + &scope, + input.collection_id, + &input.reference, + input.label.as_deref(), + ) + }) + .await +} + +#[tauri::command] +pub async fn remove_collection_member( + relay_url: String, + owner_pubkey: String, + collection_id: String, + member_id: String, + app: AppHandle, +) -> Result<(), String> { + let path = database_path(&app)?; + let scope = CollectionScope::new(&relay_url, &owner_pubkey).map_err(render_error)?; + let collection_id = parse_uuid(&collection_id, "collection")?; + let member_id = parse_uuid(&member_id, "member")?; + with_store(path, move |store| { + store.remove_member(&scope, collection_id, member_id) + }) + .await +} + +/// Discover current document attachments for a linked Google Calendar event. +/// +/// Collections keep the calendar event as the explicit membership edge. This +/// optional local resolver reads the user's already-connected Calendar account +/// and returns ephemeral children; it never writes those children to SQLite. +#[tauri::command] +pub async fn discover_collection_calendar_links( + url: String, +) -> Result, String> { + validate_google_calendar_event_url(&url)?; + let sq_path = crate::managed_agents::resolve_command("sq") + .ok_or_else(|| format!("{CALENDAR_UNAVAILABLE}: `sq` was not found on this device"))?; + + fetch_calendar_links(&sq_path, &url).await +} + +/// Discover ephemeral edit and comment activity for Google Drive documents +/// attached to an explicit Google Calendar event. +#[tauri::command] +pub async fn discover_collection_calendar_activity( + calendar_url: String, + start_time: String, + end_time: Option, +) -> Result { + validate_google_calendar_event_url(&calendar_url)?; + validate_activity_window(&start_time, end_time.as_deref())?; + let sq_path = crate::managed_agents::resolve_command("sq").ok_or_else(|| { + format!( + "Optional Google Calendar activity discovery is unavailable for {calendar_url}: `sq` was not found on this device" + ) + })?; + + tokio::time::timeout( + CALENDAR_ACTIVITY_TIMEOUT, + discover_calendar_activity(&sq_path, &calendar_url, &start_time, end_time.as_deref()), + ) + .await + .map_err(|_| { + format!( + "Optional Google Calendar activity discovery is unavailable for {calendar_url}: lookup timed out" + ) + })? +} + +async fn fetch_calendar_links( + sq_path: &Path, + url: &str, +) -> Result, String> { + let args = [ + "agent-tools", + "google-calendar", + "get-calendar-event", + "--event-id", + url, + "--calendar-id", + "primary", + "--output", + "json", + "--timeout", + CALENDAR_TOOL_TIMEOUT_SECS, + ]; + let stdout = run_sq_command( + sq_path, + &args, + CALENDAR_DISCOVERY_TIMEOUT, + CALENDAR_UNAVAILABLE, + ) + .await?; + parse_calendar_links(&stdout).map_err(|error| format!("{CALENDAR_UNAVAILABLE}: {error}")) +} + +async fn discover_calendar_activity( + sq_path: &Path, + calendar_url: &str, + start_time: &str, + end_time: Option<&str>, +) -> Result { + let links = fetch_calendar_links(sq_path, calendar_url) + .await + .map_err(|error| { + format!( + "Optional Google Calendar activity discovery is unavailable for {calendar_url}: {error}" + ) + })?; + let mut documents = Vec::new(); + let mut seen_file_ids = HashSet::new(); + for link in links { + let Some(file_id) = google_drive_file_id(&link.url) else { + continue; + }; + if seen_file_ids.insert(file_id.clone()) { + documents.push((file_id, link.url, link.label)); + } + } + + let mut result = CollectionCalendarActivityResult { + activities: Vec::new(), + errors: Vec::new(), + }; + if documents.len() > MAX_ACTIVITY_DOCUMENTS { + result.errors.push(CollectionActivitySourceError { + source_url: calendar_url.to_string(), + message: format!( + "Only the first {MAX_ACTIVITY_DOCUMENTS} attached Drive documents were checked" + ), + }); + documents.truncate(MAX_ACTIVITY_DOCUMENTS); + } + + for (file_id, attachment_url, attachment_label) in documents { + let metadata = match fetch_drive_metadata(sq_path, &file_id).await { + Ok(metadata) => metadata, + Err(message) => { + result.errors.push(CollectionActivitySourceError { + source_url: attachment_url, + message: format!("{attachment_label} ({file_id}): {message}"), + }); + continue; + } + }; + let activities = match fetch_drive_activity(sq_path, &file_id, start_time, end_time).await { + Ok(activities) => activities, + Err(message) => { + result.errors.push(CollectionActivitySourceError { + source_url: attachment_url, + message: format!("{} ({file_id}): {message}", metadata.title), + }); + continue; + } + }; + result + .activities + .extend( + activities + .into_iter() + .map(|activity| CollectionCalendarActivity { + action_type: activity.action_type, + timestamp: activity.timestamp, + actor_display_name: activity.actor_display_name, + actor_email: activity.actor_email, + document_title: metadata.title.clone(), + document_url: metadata.url.clone(), + document_file_id: metadata.file_id.clone(), + source_calendar_url: calendar_url.to_string(), + source_attachment_url: attachment_url.clone(), + }), + ); + } + + result.activities.sort_by(|left, right| { + right + .timestamp + .cmp(&left.timestamp) + .then_with(|| left.document_file_id.cmp(&right.document_file_id)) + .then_with(|| left.action_type.cmp(&right.action_type)) + }); + result.activities.dedup_by(|left, right| { + left.document_file_id == right.document_file_id + && left.action_type == right.action_type + && left.timestamp == right.timestamp + && left.actor_email == right.actor_email + && left.actor_display_name == right.actor_display_name + }); + Ok(result) +} + +async fn fetch_drive_metadata( + sq_path: &Path, + file_id: &str, +) -> Result { + let args = [ + "agent-tools", + "google-drive", + "get-file-metadata", + "--file-id-or-url", + file_id, + "--raw", + "--timeout", + DRIVE_TOOL_TIMEOUT_SECS, + ]; + let unavailable = "Optional Google Drive metadata is unavailable"; + let output = run_sq_command(sq_path, &args, DRIVE_PROCESS_TIMEOUT, unavailable).await?; + parse_drive_metadata(&output, file_id).map_err(|error| format!("{unavailable}: {error}")) +} + +async fn fetch_drive_activity( + sq_path: &Path, + file_id: &str, + start_time: &str, + end_time: Option<&str>, +) -> Result, String> { + let mut args = vec![ + "agent-tools", + "google-drive", + "activity", + "--start-time", + start_time, + ]; + if let Some(end_time) = end_time { + args.extend(["--end-time", end_time]); + } + args.extend([ + "--item-id", + file_id, + "--activity-type", + "edit", + "comment", + "--lookup-people", + "--raw", + "--timeout", + DRIVE_TOOL_TIMEOUT_SECS, + ]); + let unavailable = "Optional Google Drive activity is unavailable"; + let output = run_sq_command(sq_path, &args, DRIVE_PROCESS_TIMEOUT, unavailable).await?; + parse_drive_activity(&output, file_id).map_err(|error| format!("{unavailable}: {error}")) +} + +async fn run_sq_command( + sq_path: &Path, + args: &[&str], + process_timeout: Duration, + unavailable: &str, +) -> Result, String> { + let mut command = Command::new(sq_path); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut child = command + .spawn() + .map_err(|error| format!("{unavailable}: failed to start `sq`: {error}"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| format!("{unavailable}: failed to capture `sq` output"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| format!("{unavailable}: failed to capture `sq` errors"))?; + + let (status, stdout, stderr) = tokio::time::timeout(process_timeout, async move { + let (stdout, stderr, status) = tokio::join!( + read_bounded(stdout, MAX_CALENDAR_STDOUT_BYTES), + read_bounded(stderr, MAX_CALENDAR_STDERR_BYTES), + child.wait(), + ); + Ok::<_, std::io::Error>((status?, stdout?, stderr?)) + }) + .await + .map_err(|_| format!("{unavailable}: lookup timed out"))? + .map_err(|error| format!("{unavailable}: failed while running `sq`: {error}"))?; + if !status.success() { + return Err(format!( + "{unavailable}: {}", + first_safe_line(&stderr).unwrap_or("tool lookup failed") + )); + } + Ok(stdout) +} + +async fn read_bounded( + reader: impl AsyncRead + Unpin, + max_bytes: usize, +) -> Result, std::io::Error> { + let mut bytes = Vec::new(); + reader + .take((max_bytes + 1) as u64) + .read_to_end(&mut bytes) + .await?; + if bytes.len() > max_bytes { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "tool response exceeded the size limit", + )); + } + Ok(bytes) +} + +fn first_safe_line(stderr: &[u8]) -> Option<&str> { + std::str::from_utf8(stderr) + .ok()? + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.chars().any(char::is_control)) +} + +fn database_path(app: &AppHandle) -> Result { + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|error| format!("failed to resolve app data directory: {error}"))?; + collections_db_path_for_app_data_dir(&app_data_dir).map_err(render_error) +} + +async fn with_store(path: PathBuf, operation: F) -> Result +where + T: Send + 'static, + F: FnOnce(&mut CollectionsStore) -> Result + Send + 'static, +{ + tokio::task::spawn_blocking(move || { + let mut store = CollectionsStore::open(path)?; + operation(&mut store) + }) + .await + .map_err(|error| format!("collections task failed: {error}"))? + .map_err(render_error) +} + +fn parse_uuid(value: &str, field: &str) -> Result { + Uuid::parse_str(value).map_err(|_| format!("invalid {field} UUID: {value}")) +} + +fn render_error(error: CollectionsError) -> String { + error.to_string() +} + +fn validate_google_calendar_event_url(raw: &str) -> Result<(), String> { + if raw != raw.trim() || raw.len() > 8 * 1024 || raw.chars().any(char::is_control) { + return Err("invalid Google Calendar event URL".to_string()); + } + let url = url::Url::parse(raw).map_err(|_| "invalid Google Calendar event URL".to_string())?; + let is_google_host = matches!( + url.host_str(), + Some("www.google.com" | "calendar.google.com") + ); + let event_ids = url + .query_pairs() + .filter(|(key, _)| key == "eid") + .collect::>(); + if url.scheme() != "https" + || !is_google_host + || !url.username().is_empty() + || url.password().is_some() + || url.port().is_some() + || url.path() != "/calendar/event" + || url.fragment().is_some() + || event_ids.len() != 1 + || event_ids[0].1.is_empty() + || event_ids[0].1.chars().any(char::is_control) + { + return Err("URL is not a supported Google Calendar event".to_string()); + } + Ok(()) +} + +fn validate_activity_window(start_time: &str, end_time: Option<&str>) -> Result<(), String> { + let start = DateTime::parse_from_rfc3339(start_time) + .map_err(|_| "invalid Drive activity start time; expected RFC 3339".to_string())?; + if let Some(end_time) = end_time { + let end = DateTime::parse_from_rfc3339(end_time) + .map_err(|_| "invalid Drive activity end time; expected RFC 3339".to_string())?; + if end <= start { + return Err("Drive activity end time must be after start time".to_string()); + } + } + Ok(()) +} + +fn google_drive_file_id(raw: &str) -> Option { + if raw != raw.trim() || raw.len() > 8 * 1024 || raw.chars().any(char::is_control) { + return None; + } + let url = url::Url::parse(raw).ok()?; + if url.scheme() != "https" + || !url.username().is_empty() + || url.password().is_some() + || url.port().is_some() + || url.fragment().is_some() + { + return None; + } + let host = url.host_str()?; + let segments = url.path_segments()?.collect::>(); + let candidate = match host { + "docs.google.com" + if segments.first() == Some(&"document") && segments.get(1) == Some(&"d") => + { + segments.get(2).copied() + } + "drive.google.com" + if segments.first() == Some(&"file") && segments.get(1) == Some(&"d") => + { + segments.get(2).copied() + } + "drive.google.com" if url.path() == "/open" => { + let ids = url + .query_pairs() + .filter(|(key, _)| key == "id") + .map(|(_, value)| value.into_owned()) + .collect::>(); + if ids.len() == 1 { + return valid_drive_file_id(&ids[0]).then(|| ids[0].clone()); + } + None + } + _ => None, + }?; + valid_drive_file_id(candidate).then(|| candidate.to_string()) +} + +fn valid_drive_file_id(candidate: &str) -> bool { + (10..=128).contains(&candidate.len()) + && candidate + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn parse_drive_metadata( + output: &[u8], + expected_file_id: &str, +) -> Result { + let value: Value = serde_json::from_slice(output) + .map_err(|error| format!("invalid Drive metadata response: {error}"))?; + if let Some(metadata) = find_drive_metadata(&value, expected_file_id) { + return Ok(metadata); + } + let mut texts = Vec::new(); + collect_content_text(&value, &mut texts); + texts + .iter() + .find_map(|text| parse_drive_metadata_text(text, expected_file_id)) + .ok_or_else(|| "metadata did not identify the attached Google document".to_string()) +} + +fn find_drive_metadata(value: &Value, expected_file_id: &str) -> Option { + match value { + Value::Object(object) => { + let file_id = ["id", "fileId", "file_id"] + .iter() + .find_map(|key| object.get(*key).and_then(Value::as_str)); + let mime_type = ["mimeType", "mime_type"] + .iter() + .find_map(|key| object.get(*key).and_then(Value::as_str)); + let title = ["name", "title"] + .iter() + .find_map(|key| object.get(*key).and_then(Value::as_str)); + let document_url = ["webViewLink", "web_view_link", "url"] + .iter() + .find_map(|key| object.get(*key).and_then(Value::as_str)); + if file_id == Some(expected_file_id) + && mime_type == Some("application/vnd.google-apps.document") + { + let title = title?.trim(); + let document_url = document_url?.trim(); + if !title.is_empty() + && !title.chars().any(char::is_control) + && google_drive_file_id(document_url).as_deref() == Some(expected_file_id) + { + return Some(DriveDocumentMetadata { + file_id: expected_file_id.to_string(), + title: title.to_string(), + url: document_url.to_string(), + }); + } + } + for child in object.values() { + if let Some(metadata) = find_drive_metadata(child, expected_file_id) { + return Some(metadata); + } + } + None + } + Value::Array(values) => values + .iter() + .find_map(|child| find_drive_metadata(child, expected_file_id)), + Value::String(text) => parse_embedded_json(text) + .as_ref() + .and_then(|embedded| find_drive_metadata(embedded, expected_file_id)), + _ => None, + } +} + +fn parse_embedded_json(text: &str) -> Option { + let trimmed = text.trim(); + let trimmed = trimmed + .strip_prefix("```json") + .or_else(|| trimmed.strip_prefix("```")) + .unwrap_or(trimmed) + .strip_suffix("```") + .unwrap_or(trimmed) + .trim(); + serde_json::from_str(trimmed).ok() +} + +fn parse_drive_metadata_text(text: &str, expected_file_id: &str) -> Option { + let mut file_id = None; + let mut title = None; + let mut mime_type = None; + let mut document_url = None; + for line in text.lines() { + let line = line.trim().trim_start_matches(['-', '*', '#']).trim(); + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let key = key + .trim_matches(|character: char| { + matches!(character, '*' | '`') || character.is_whitespace() + }) + .to_ascii_lowercase(); + let value = value.trim_matches(|character: char| { + matches!(character, '*' | '`') || character.is_whitespace() + }); + match key.as_str() { + "id" | "file id" => file_id = Some(value), + "name" | "title" => title = Some(value), + "mime type" | "mimetype" => mime_type = Some(value), + "web view link" | "webviewlink" | "url" => document_url = Some(value), + _ => {} + } + } + if file_id != Some(expected_file_id) + || mime_type != Some("application/vnd.google-apps.document") + { + return None; + } + let title = title?; + let document_url = document_url?; + if title.is_empty() + || title.chars().any(char::is_control) + || google_drive_file_id(document_url).as_deref() != Some(expected_file_id) + { + return None; + } + Some(DriveDocumentMetadata { + file_id: expected_file_id.to_string(), + title: title.to_string(), + url: document_url.to_string(), + }) +} + +fn parse_drive_activity( + output: &[u8], + expected_file_id: &str, +) -> Result, String> { + let Some(text) = drive_activity_text(output)? else { + return Ok(Vec::new()); + }; + let mut activities = text + .lines() + .filter_map(|line| parse_drive_activity_line(line, expected_file_id)) + .collect::>(); + activities.sort_by(|left, right| right.timestamp.cmp(&left.timestamp)); + activities.dedup(); + if activities.is_empty() && !is_empty_activity_text(&text) { + return Err( + "Drive activity response contained no recognized edit or comment events".into(), + ); + } + Ok(activities) +} + +fn is_empty_activity_text(text: &str) -> bool { + let text = text.trim(); + if text.is_empty() { + return true; + } + let lowercase = text.to_ascii_lowercase(); + if lowercase.contains("no activity") + || lowercase.contains("no recent activity") + || lowercase.contains("no drive activity") + || lowercase.contains("no matching activity") + || lowercase.contains("no events") + { + return true; + } + parse_embedded_json(text) + .as_ref() + .is_some_and(is_explicit_empty_activity_value) +} + +fn drive_activity_text(output: &[u8]) -> Result, String> { + let raw = std::str::from_utf8(output) + .map_err(|_| "Drive activity response was not UTF-8".to_string())?; + if raw.trim().is_empty() { + return Ok(None); + } + let Ok(value) = serde_json::from_str::(raw) else { + return Ok(Some(raw.to_string())); + }; + let mut texts = Vec::new(); + collect_content_text(&value, &mut texts); + texts.retain(|text| !text.trim().is_empty()); + if texts.is_empty() && is_explicit_empty_activity_response(&value) { + return Ok(None); + } + if texts.is_empty() { + return Err("Drive activity response did not contain text content".into()); + } + Ok(Some(texts.join("\n"))) +} + +fn is_explicit_empty_activity_response(value: &Value) -> bool { + match value { + Value::Object(object) => { + if object.get("is_error").and_then(Value::as_bool) == Some(true) + || object.get("isError").and_then(Value::as_bool) == Some(true) + { + return false; + } + [ + "content", + "activities", + "activity", + "events", + "items", + "result", + ] + .iter() + .any(|key| { + object + .get(*key) + .is_some_and(is_explicit_empty_activity_value) + }) + } + _ => false, + } +} + +fn is_explicit_empty_activity_value(value: &Value) -> bool { + match value { + Value::Null => true, + Value::Array(values) => { + values.is_empty() || values.iter().all(is_explicit_empty_activity_value) + } + Value::String(text) => text.trim().is_empty(), + Value::Object(object) => { + if let Some(text) = object.get("text") { + return is_explicit_empty_activity_value(text); + } + ["activities", "activity", "events", "items", "result"] + .iter() + .any(|key| { + object + .get(*key) + .is_some_and(is_explicit_empty_activity_value) + }) + } + _ => false, + } +} + +fn collect_content_text(value: &Value, texts: &mut Vec) { + match value { + Value::Object(object) => { + if let Some(text) = object.get("text").and_then(Value::as_str) { + texts.push(text.to_string()); + return; + } + for child in object.values() { + collect_content_text(child, texts); + } + } + Value::Array(values) => { + for child in values { + collect_content_text(child, texts); + } + } + _ => {} + } +} + +fn parse_drive_activity_line(line: &str, expected_file_id: &str) -> Option { + let line = line.trim().strip_prefix("- ").unwrap_or(line.trim()); + let after_open = line.strip_prefix('[')?; + let (timestamp, rest) = after_open.split_once("] ")?; + let timestamp = parse_drive_activity_timestamp(timestamp)?; + let (action, rest) = rest.split_once(" by ")?; + let action = action.to_ascii_lowercase(); + let action_type = if action.contains("comment") { + "comment" + } else if action.contains("edit") { + "edit" + } else { + return None; + }; + let (actor, target) = rest.split_once(" on \"")?; + let (_, detail) = target.rsplit_once("\" (")?; + let reported_file_id = detail + .split("id: ") + .nth(1)? + .split([',', ')']) + .next()? + .trim(); + let file_id = reported_file_id + .strip_prefix("items/") + .unwrap_or(reported_file_id); + if file_id != expected_file_id || !valid_drive_file_id(file_id) { + return None; + } + let (actor_display_name, actor_email) = parse_drive_actor(actor.trim()); + Some(ParsedDriveActivity { + action_type: action_type.to_string(), + timestamp, + actor_display_name, + actor_email, + }) +} + +fn parse_drive_activity_timestamp(raw: &str) -> Option { + let timestamp = DateTime::parse_from_rfc3339(raw) + .map(|timestamp| timestamp.with_timezone(&Utc)) + .or_else(|_| { + NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M:%S%.f") + .map(|timestamp| timestamp.and_utc()) + }) + .ok()?; + Some(timestamp.to_rfc3339_opts(SecondsFormat::Millis, true)) +} + +fn parse_drive_actor(actor: &str) -> (Option, Option) { + let actor = actor.trim(); + if let Some((name, email)) = actor + .strip_suffix(')') + .and_then(|value| value.rsplit_once(" (")) + { + if valid_actor_email(email) { + return (nonempty_actor_name(name), Some(email.to_string())); + } + } + if let Some((name, email)) = actor + .strip_suffix('>') + .and_then(|value| value.rsplit_once(" <")) + { + if valid_actor_email(email) { + return (nonempty_actor_name(name), Some(email.to_string())); + } + } + if valid_actor_email(actor) { + return (None, Some(actor.to_string())); + } + (nonempty_actor_name(actor), None) +} + +fn nonempty_actor_name(name: &str) -> Option { + let name = name.trim(); + (!name.is_empty() && name.len() <= 256 && !name.chars().any(char::is_control)) + .then(|| name.to_string()) +} + +fn valid_actor_email(email: &str) -> bool { + email.len() <= 254 + && email.contains('@') + && !email + .chars() + .any(|character| character.is_whitespace() || character.is_control()) +} + +fn parse_calendar_links(output: &[u8]) -> Result, String> { + let response: CalendarToolResponse = serde_json::from_slice(output) + .map_err(|error| format!("invalid Google Calendar response: {error}"))?; + let attachments = response + .result + .map_or(response.attachments, |result| result.attachments); + let mut links = Vec::new(); + for attachment in attachments { + if attachment.file_url != attachment.file_url.trim() + || attachment.file_url.chars().any(char::is_control) + { + continue; + } + let Ok(url) = url::Url::parse(&attachment.file_url) else { + continue; + }; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || google_drive_file_id(url.as_str()).is_none() + { + continue; + } + links.push(CollectionDiscoveredLink { + url: url.to_string(), + label: if attachment.title.trim().is_empty() { + url.host_str().unwrap_or_default().to_string() + } else { + attachment.title.trim().to_string() + }, + kind: "document".to_string(), + }); + } + links.sort_by(|left, right| left.url.cmp(&right.url)); + links.dedup_by(|left, right| left.url == right.url); + Ok(links) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_ids_are_validated_before_store_access() { + assert!(parse_uuid("not-a-uuid", "collection").is_err()); + assert!(parse_uuid("9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", "collection").is_ok()); + } + + #[test] + fn add_member_input_accepts_desktop_thread_payload() { + let input: AddCollectionMemberInput = serde_json::from_value(serde_json::json!({ + "relay_url": "wss://buzz.block.builderlab.xyz", + "owner_pubkey": "67252b09c31a995daa63aada26569fbc6a3d12f573113f001ce7432f870da820", + "collection_id": "593a7412-49ee-4709-ac66-17f6e6288279", + "reference": { + "type": "thread", + "channel_id": "7f4a6441-4028-4cf5-8872-de456856d68e", + "root_event_id": "b016509b88c9c1cdc615f7101b779104a3101758f54e6a48a10a2e51843ff358" + }, + "label": "Thread: production payload" + })) + .expect("desktop thread payload should deserialize"); + + assert!(matches!( + input.reference, + CollectionReference::Thread { .. } + )); + } + + #[test] + fn icon_inputs_accept_create_set_and_clear_payloads() { + let create: CreateCollectionInput = serde_json::from_value(serde_json::json!({ + "relay_url": "wss://buzz.block.builderlab.xyz", + "owner_pubkey": "67252b09c31a995daa63aada26569fbc6a3d12f573113f001ce7432f870da820", + "name": "Bird Voice", + "description": null, + "icon": "🐦" + })) + .expect("create icon payload"); + assert_eq!(create.icon.as_deref(), Some("🐦")); + + let set: SetCollectionIconInput = serde_json::from_value(serde_json::json!({ + "relay_url": "wss://buzz.block.builderlab.xyz", + "owner_pubkey": "67252b09c31a995daa63aada26569fbc6a3d12f573113f001ce7432f870da820", + "collection_id": "593a7412-49ee-4709-ac66-17f6e6288279", + "icon": "🎧" + })) + .expect("set icon payload"); + assert_eq!(set.icon.as_deref(), Some("🎧")); + + let clear: SetCollectionIconInput = serde_json::from_value(serde_json::json!({ + "relay_url": "wss://buzz.block.builderlab.xyz", + "owner_pubkey": "67252b09c31a995daa63aada26569fbc6a3d12f573113f001ce7432f870da820", + "collection_id": "593a7412-49ee-4709-ac66-17f6e6288279", + "icon": null + })) + .expect("clear icon payload"); + assert_eq!(clear.icon, None); + } + + #[test] + fn rename_input_accepts_desktop_payload() { + let input: SetCollectionNameInput = serde_json::from_value(serde_json::json!({ + "relay_url": "wss://buzz.block.builderlab.xyz", + "owner_pubkey": "67252b09c31a995daa63aada26569fbc6a3d12f573113f001ce7432f870da820", + "collection_id": "593a7412-49ee-4709-ac66-17f6e6288279", + "name": "Berd Voice" + })) + .expect("rename payload"); + assert_eq!(input.name, "Berd Voice"); + } + + #[test] + fn calendar_discovery_only_accepts_google_event_links() { + assert!(validate_google_calendar_event_url( + "https://www.google.com/calendar/event?eid=abc123&ctz=UTC" + ) + .is_ok()); + assert!( + validate_google_calendar_event_url("https://example.com/calendar/event?eid=abc") + .is_err() + ); + assert!( + validate_google_calendar_event_url("https://www.google.com/calendar/event").is_err() + ); + assert!(validate_google_calendar_event_url( + "https://attacker@example.com/calendar/event?eid=abc" + ) + .is_err()); + assert!(validate_google_calendar_event_url( + "https://www.google.com:444/calendar/event?eid=abc" + ) + .is_err()); + assert!(validate_google_calendar_event_url( + "https://www.google.com/calendar/event?eid=abc#fragment" + ) + .is_err()); + assert!(validate_google_calendar_event_url( + "https://www.google.com/calendar/event?eid=abc&eid=second" + ) + .is_err()); + } + + #[test] + fn calendar_discovery_returns_document_links_without_persisting_them() { + let file_id = "1AbCdEfGhIjKlMnOpQrStUvWxYz_123456"; + let output = serde_json::json!({ + "result": { + "attachments": [ + {"fileUrl": format!("https://docs.google.com/document/d/{file_id}/edit"), "title": "Notes by Gemini"}, + {"fileUrl": "file:///tmp/private", "title": "Local file"}, + {"fileUrl": "https://user:secret@example.com/private", "title": "Credentials"}, + {"fileUrl": "https://example.com/meeting-notes.pdf", "title": "Arbitrary PDF"}, + {"fileUrl": "https://docs.google.com/document/d/too-short/edit", "title": "Malformed Drive ID"}, + {"fileUrl": format!("https://docs.google.com/document/d/{file_id}/edit"), "title": "Duplicate"} + ] + } + }); + let links = parse_calendar_links(output.to_string().as_bytes()).expect("calendar links"); + assert_eq!( + links, + vec![CollectionDiscoveredLink { + url: format!("https://docs.google.com/document/d/{file_id}/edit"), + label: "Notes by Gemini".to_string(), + kind: "document".to_string(), + }] + ); + } + + #[test] + fn calendar_discovery_accepts_direct_event_json() { + let drive_file_id = "1DriveFileAttachmentId_123456789"; + let docs_file_id = "1DocsFileAttachmentId_1234567890"; + let output = serde_json::json!({ + "attachments": [ + {"fileUrl": format!("https://drive.google.com/file/d/{drive_file_id}/view"), "title": " Drive file "}, + {"fileUrl": "javascript:alert(1)", "title": "Unsafe"}, + {"fileUrl": format!("https://docs.google.com/document/d/{docs_file_id}/edit"), "title": ""}, + {"fileUrl": "https://example.com/no-title", "title": "Website"} + ] + }); + let links = parse_calendar_links(output.to_string().as_bytes()).expect("calendar links"); + assert_eq!(links.len(), 2); + assert_eq!(links[0].label, "docs.google.com"); + assert_eq!(links[1].label, "Drive file"); + } + + #[test] + fn drive_file_ids_only_come_from_strict_attachment_urls() { + let file_id = "1AbCdEfGhIjKlMnOpQrStUvWxYz_123456"; + assert_eq!( + google_drive_file_id(&format!( + "https://docs.google.com/document/d/{file_id}/edit" + )), + Some(file_id.to_string()) + ); + assert_eq!( + google_drive_file_id(&format!("https://drive.google.com/file/d/{file_id}/view")), + Some(file_id.to_string()) + ); + assert_eq!( + google_drive_file_id(&format!("https://drive.google.com/open?id={file_id}")), + Some(file_id.to_string()) + ); + assert!(google_drive_file_id(&format!( + "https://docs.google.com.evil.example/document/d/{file_id}/edit" + )) + .is_none()); + assert!(google_drive_file_id(&format!( + "https://user@docs.google.com/document/d/{file_id}/edit" + )) + .is_none()); + assert!(google_drive_file_id(&format!( + "https://docs.google.com/spreadsheets/d/{file_id}/edit" + )) + .is_none()); + assert!(google_drive_file_id("https://drive.google.com/file/d/too-short/view").is_none()); + } + + #[test] + fn drive_metadata_must_match_the_attached_google_doc() { + let file_id = "1AbCdEfGhIjKlMnOpQrStUvWxYz_123456"; + let output = serde_json::json!({ + "content": [{"type": "text", "text": "metadata loaded"}], + "structuredContent": { + "id": file_id, + "name": "Launch notes", + "mimeType": "application/vnd.google-apps.document", + "webViewLink": format!("https://docs.google.com/document/d/{file_id}/edit") + } + }); + let metadata = + parse_drive_metadata(output.to_string().as_bytes(), file_id).expect("metadata"); + assert_eq!(metadata.title, "Launch notes"); + assert_eq!(metadata.file_id, file_id); + + assert!( + parse_drive_metadata(output.to_string().as_bytes(), "differentFileId12345").is_err() + ); + } + + #[test] + fn drive_metadata_accepts_formatted_raw_content() { + let file_id = "1AbCdEfGhIjKlMnOpQrStUvWxYz_123456"; + let output = serde_json::json!({ + "content": [{ + "type": "text", + "text": format!( + "## File metadata\n- **ID:** `{file_id}`\n- **Name:** Launch notes\n- **MIME type:** application/vnd.google-apps.document\n- **Web view link:** https://docs.google.com/document/d/{file_id}/edit" + ) + }] + }); + let metadata = + parse_drive_metadata(output.to_string().as_bytes(), file_id).expect("metadata"); + assert_eq!(metadata.title, "Launch notes"); + } + + #[test] + fn drive_activity_parses_realistic_edit_and_comment_output() { + let file_id = "1AbCdEfGhIjKlMnOpQrStUvWxYz_123456"; + let raw = serde_json::json!({ + "content": [{ + "text": { + "text": format!( + "- [2026-08-27T13:42:01Z] EDIT by Ada Lovelace (ada@example.com) on \"Launch notes\" (type: drive#file, id: {file_id})\n- [2026-08-27T14:05:03-04:00] COMMENT by Grace Hopper on \"Launch notes\" (type: drive#file, id: {file_id})\n- [2026-08-27T14:06:00Z] RENAME by Someone on \"Launch notes\" (type: drive#file, id: {file_id})\n- [2026-08-27T14:07:00Z] EDIT by Attacker on \"Other\" (type: drive#file, id: 1OtherValidatedFileId987654321)" + ) + } + }] + }); + let activities = + parse_drive_activity(raw.to_string().as_bytes(), file_id).expect("activity"); + assert_eq!(activities.len(), 2); + assert_eq!(activities[0].action_type, "comment"); + assert_eq!(activities[0].timestamp, "2026-08-27T18:05:03.000Z"); + assert_eq!( + activities[0].actor_display_name.as_deref(), + Some("Grace Hopper") + ); + assert_eq!( + activities[0].actor_email.as_deref(), + Some("grace@example.com") + ); + assert_eq!(activities[1].action_type, "edit"); + assert_eq!( + activities[1].actor_display_name.as_deref(), + Some("Ada Lovelace") + ); + assert_eq!( + activities[1].actor_email.as_deref(), + Some("ada@example.com") + ); + } + + #[test] + fn drive_activity_parses_live_naive_utc_timestamp_and_item_id() { + let file_id = "1AbCdEfGhIjKlMnOpQrStUvWxYz_123456"; + let raw = serde_json::json!({ + "content": [{ + "text": { + "text": format!( + "- [2026-08-27 18:09:30] EDIT by Ada Lovelace (ada@example.com) on \"Launch notes\" (type: drive#file, id: items/{file_id})\n- [2026-08-27 18:10:31] COMMENT by Grace Hopper on \"Launch notes\" (type: drive#file, id: items/{file_id})\n- [2026-08-27 18:11:32] EDIT by Wrong File on \"Other\" (type: drive#file, id: items/1OtherValidatedFileId987654321)" + ) + } + }], + "is_error": false + }); + let activities = + parse_drive_activity(raw.to_string().as_bytes(), file_id).expect("live activity"); + assert_eq!(activities.len(), 2); + assert_eq!(activities[0].action_type, "comment"); + assert_eq!(activities[0].timestamp, "2026-08-27T18:10:31.000Z"); + assert_eq!(activities[1].action_type, "edit"); + assert_eq!(activities[1].timestamp, "2026-08-27T18:09:30.000Z"); + } + + #[test] + fn drive_activity_accepts_explicit_empty_structured_responses() { + let file_id = "1AbCdEfGhIjKlMnOpQrStUvWxYz_123456"; + for output in [ + serde_json::json!({"content": [], "is_error": false}), + serde_json::json!({ + "content": [{"text": {"text": ""}}], + "is_error": false + }), + serde_json::json!({ + "content": [], + "structuredContent": {"activities": []}, + "is_error": false + }), + serde_json::json!({ + "content": [{"text": {"text": "No Drive activity found."}}], + "is_error": false + }), + serde_json::json!({ + "content": [ + {"text": {"text": "No recent activity found."}}, + {"structured_content": {"data": {"result": "No recent activity found."}}} + ], + "is_error": false, + "structured_content_json": "{\"result\":\"No recent activity found.\"}" + }), + serde_json::json!({ + "content": [{"text": {"text": "[]"}}], + "is_error": false + }), + ] { + assert!(parse_drive_activity(output.to_string().as_bytes(), file_id) + .expect("empty activity response") + .is_empty()); + } + } + + #[test] + fn drive_activity_rejects_malformed_nonempty_responses() { + let file_id = "1AbCdEfGhIjKlMnOpQrStUvWxYz_123456"; + let output = serde_json::json!({ + "content": [{"text": {"text": "unexpected nonempty response"}}], + "is_error": false + }); + assert!(parse_drive_activity(output.to_string().as_bytes(), file_id).is_err()); + } + + #[test] + fn calendar_activity_serializes_provenance() { + let activity = CollectionCalendarActivity { + action_type: "comment".into(), + timestamp: "2026-08-27T18:05:03.000Z".into(), + actor_display_name: Some("Grace Hopper".into()), + actor_email: Some("grace@example.com".into()), + document_title: "Launch notes".into(), + document_url: + "https://docs.google.com/document/d/1AbCdEfGhIjKlMnOpQrStUvWxYz_123456/edit".into(), + document_file_id: "1AbCdEfGhIjKlMnOpQrStUvWxYz_123456".into(), + source_calendar_url: "https://www.google.com/calendar/event?eid=calendar-source".into(), + source_attachment_url: + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz_123456/view".into(), + }; + let value = serde_json::to_value(activity).expect("serialize activity"); + assert_eq!(value["action_type"], "comment"); + assert_eq!( + value["source_calendar_url"], + "https://www.google.com/calendar/event?eid=calendar-source" + ); + assert!(value["source_attachment_url"] + .as_str() + .is_some_and(|url| url.contains("drive.google.com/file/d/"))); + } + + #[test] + fn drive_activity_window_is_bounded_by_valid_rfc3339_inputs() { + assert!( + validate_activity_window("2026-08-26T00:00:00Z", Some("2026-08-27T00:00:00Z")).is_ok() + ); + assert!(validate_activity_window("yesterday", None).is_err()); + assert!( + validate_activity_window("2026-08-27T00:00:00Z", Some("2026-08-26T00:00:00Z")).is_err() + ); + } + + #[tokio::test] + async fn calendar_tool_output_is_bounded() { + let error = read_bounded(&b"12345"[..], 4) + .await + .expect_err("oversized output should fail"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + } +} diff --git a/desktop/src-tauri/src/commands/collections_github.rs b/desktop/src-tauri/src/commands/collections_github.rs new file mode 100644 index 0000000000..a449f028ce --- /dev/null +++ b/desktop/src-tauri/src/commands/collections_github.rs @@ -0,0 +1,543 @@ +use std::{path::Path, process::Stdio, time::Duration}; + +use serde::{Deserialize, Serialize}; +use tokio::{ + io::{AsyncRead, AsyncReadExt}, + process::Command, +}; + +const GITHUB_LOOKUP_TIMEOUT: Duration = Duration::from_secs(12); +const MAX_GITHUB_STDOUT_BYTES: usize = 256 * 1024; +const MAX_GITHUB_STDERR_BYTES: usize = 32 * 1024; +const MAX_GITHUB_ACTIVITY: usize = 12; +const GITHUB_UNAVAILABLE: &str = "Optional GitHub pull request activity is unavailable"; +const GITHUB_PR_QUERY: &str = r#"query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){title state author{login avatarUrl} updatedAt url mergedAt mergedBy{login avatarUrl} reviews(last:8){nodes{state submittedAt author{login avatarUrl}}} comments(last:8){nodes{createdAt author{login avatarUrl} url}}}}}"#; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CollectionGithubPullRequest { + pub url: String, + pub title: String, + pub state: String, + pub author: Option, + pub author_avatar_url: Option, + pub updated_at: String, + pub activity: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CollectionGithubPullRequestActivity { + pub kind: String, + pub author: Option, + pub author_avatar_url: Option, + pub state: Option, + pub created_at: String, + pub url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct GithubPullRequestLocator { + owner: String, + repository: String, + number: u32, + url: String, +} + +#[derive(Debug, Deserialize)] +struct GithubGraphqlResponse { + data: Option, +} + +#[derive(Debug, Deserialize)] +struct GithubGraphqlData { + repository: Option, +} + +#[derive(Debug, Deserialize)] +struct GithubRepository { + #[serde(rename = "pullRequest")] + pull_request: Option, +} + +#[derive(Debug, Deserialize)] +struct GithubPullRequest { + title: String, + state: String, + author: Option, + #[serde(rename = "updatedAt")] + updated_at: String, + url: String, + #[serde(rename = "mergedAt")] + merged_at: Option, + #[serde(rename = "mergedBy")] + merged_by: Option, + reviews: GithubReviews, + comments: GithubComments, +} + +#[derive(Debug, Deserialize)] +struct GithubActor { + login: String, + #[serde(rename = "avatarUrl")] + avatar_url: Option, +} + +#[derive(Debug, Deserialize)] +struct GithubReviews { + #[serde(default)] + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +struct GithubReview { + state: String, + #[serde(rename = "submittedAt")] + submitted_at: Option, + author: Option, +} + +#[derive(Debug, Deserialize)] +struct GithubComments { + #[serde(default)] + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +struct GithubComment { + #[serde(rename = "createdAt")] + created_at: String, + author: Option, + url: String, +} + +/// Resolve current GitHub state for a PR URL derived from an explicitly-added +/// message or thread. The result is ephemeral and is never written to storage. +#[tauri::command] +pub async fn resolve_collection_github_pull_request( + url: String, +) -> Result { + let locator = parse_github_pull_request_url(&url)?; + let gh_path = crate::managed_agents::resolve_command("gh").ok_or_else(|| { + format!("{GITHUB_UNAVAILABLE}: GitHub CLI (`gh`) was not found on this device") + })?; + let output = fetch_github_pull_request(&gh_path, &locator).await?; + parse_github_pull_request(&output, &locator) +} + +async fn fetch_github_pull_request( + gh_path: &Path, + locator: &GithubPullRequestLocator, +) -> Result, String> { + let owner = format!("owner={}", locator.owner); + let repository = format!("name={}", locator.repository); + let number = format!("number={}", locator.number); + let query = format!("query={GITHUB_PR_QUERY}"); + let mut command = Command::new(gh_path); + command + .args([ + "api", + "graphql", + "--hostname", + "github.com", + "-f", + &query, + "-F", + &owner, + "-F", + &repository, + "-F", + &number, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut child = command + .spawn() + .map_err(|error| format!("{GITHUB_UNAVAILABLE}: failed to start `gh`: {error}"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| format!("{GITHUB_UNAVAILABLE}: failed to capture `gh` output"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| format!("{GITHUB_UNAVAILABLE}: failed to capture `gh` errors"))?; + + let (status, stdout, stderr) = tokio::time::timeout(GITHUB_LOOKUP_TIMEOUT, async move { + let (stdout, stderr, status) = tokio::join!( + read_bounded(stdout, MAX_GITHUB_STDOUT_BYTES), + read_bounded(stderr, MAX_GITHUB_STDERR_BYTES), + child.wait(), + ); + Ok::<_, std::io::Error>((status?, stdout?, stderr?)) + }) + .await + .map_err(|_| format!("{GITHUB_UNAVAILABLE}: lookup timed out"))? + .map_err(|error| format!("{GITHUB_UNAVAILABLE}: failed while running `gh`: {error}"))?; + if !status.success() { + return Err(format!( + "{GITHUB_UNAVAILABLE}: {}", + first_safe_line(&stderr).unwrap_or("GitHub lookup failed") + )); + } + Ok(stdout) +} + +fn parse_github_pull_request( + output: &[u8], + locator: &GithubPullRequestLocator, +) -> Result { + let response: GithubGraphqlResponse = serde_json::from_slice(output) + .map_err(|error| format!("{GITHUB_UNAVAILABLE}: invalid `gh` response: {error}"))?; + let pull_request = response + .data + .and_then(|data| data.repository) + .and_then(|repository| repository.pull_request) + .ok_or_else(|| { + format!("{GITHUB_UNAVAILABLE}: pull request was not found or is not accessible") + })?; + let resolved_locator = parse_github_pull_request_url(&pull_request.url).map_err(|_| { + format!("{GITHUB_UNAVAILABLE}: GitHub returned an invalid pull request URL") + })?; + if !resolved_locator.owner.eq_ignore_ascii_case(&locator.owner) + || !resolved_locator + .repository + .eq_ignore_ascii_case(&locator.repository) + || resolved_locator.number != locator.number + { + return Err(format!( + "{GITHUB_UNAVAILABLE}: GitHub returned a different pull request" + )); + } + + let mut activity = pull_request + .reviews + .nodes + .into_iter() + .filter_map(|review| { + let (author, author_avatar_url) = github_actor_fields(review.author); + Some(CollectionGithubPullRequestActivity { + kind: "review".to_string(), + author, + author_avatar_url, + state: Some(review.state.to_ascii_lowercase()), + created_at: review.submitted_at?, + url: None, + }) + }) + .chain(pull_request.comments.nodes.into_iter().map(|comment| { + let (author, author_avatar_url) = github_actor_fields(comment.author); + CollectionGithubPullRequestActivity { + kind: "comment".to_string(), + author, + author_avatar_url, + state: None, + created_at: comment.created_at, + url: Some(comment.url), + } + })) + .collect::>(); + if let Some(created_at) = pull_request.merged_at { + let (author, author_avatar_url) = github_actor_fields(pull_request.merged_by); + activity.push(CollectionGithubPullRequestActivity { + kind: "merge".to_string(), + author, + author_avatar_url, + state: None, + created_at, + url: Some(resolved_locator.url.clone()), + }); + } + activity.sort_by(|left, right| right.created_at.cmp(&left.created_at)); + activity.truncate(MAX_GITHUB_ACTIVITY); + let (author, author_avatar_url) = github_actor_fields(pull_request.author); + + Ok(CollectionGithubPullRequest { + url: resolved_locator.url, + title: pull_request.title, + state: pull_request.state.to_ascii_lowercase(), + author, + author_avatar_url, + updated_at: pull_request.updated_at, + activity, + }) +} + +fn github_actor_fields(actor: Option) -> (Option, Option) { + match actor { + Some(actor) => ( + Some(actor.login), + actor + .avatar_url + .as_deref() + .and_then(normalize_github_avatar_url), + ), + None => (None, None), + } +} + +fn normalize_github_avatar_url(raw: &str) -> Option { + if raw != raw.trim() || raw.len() > 2_048 || raw.chars().any(char::is_control) { + return None; + } + let parsed = url::Url::parse(raw).ok()?; + if parsed.scheme() != "https" + || !matches!( + parsed.host_str(), + Some("avatars.githubusercontent.com" | "github.com") + ) + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.port().is_some() + || parsed.fragment().is_some() + || parsed.path().is_empty() + || parsed.path() == "/" + { + return None; + } + Some(parsed.to_string()) +} + +fn parse_github_pull_request_url(raw: &str) -> Result { + if raw != raw.trim() || raw.len() > 2_048 || raw.chars().any(char::is_control) { + return Err("invalid GitHub pull request URL".to_string()); + } + let parsed = url::Url::parse(raw).map_err(|_| "invalid GitHub pull request URL".to_string())?; + if parsed.scheme() != "https" + || parsed.host_str() != Some("github.com") + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.port().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err("URL is not a supported GitHub pull request".to_string()); + } + let mut segments = parsed + .path_segments() + .ok_or_else(|| "URL is not a supported GitHub pull request".to_string())? + .collect::>(); + if segments.last() == Some(&"") { + segments.pop(); + } + if segments.len() != 4 || segments[2] != "pull" { + return Err("URL is not a supported GitHub pull request".to_string()); + } + let owner = segments[0]; + let repository = segments[1]; + if !valid_github_owner(owner) || !valid_github_repository(repository) { + return Err("URL is not a supported GitHub pull request".to_string()); + } + let number = segments[3] + .parse::() + .ok() + .filter(|number| *number > 0) + .ok_or_else(|| "URL is not a supported GitHub pull request".to_string())?; + Ok(GithubPullRequestLocator { + owner: owner.to_string(), + repository: repository.to_string(), + number, + url: format!("https://github.com/{owner}/{repository}/pull/{number}"), + }) +} + +fn valid_github_owner(value: &str) -> bool { + (1..=39).contains(&value.len()) + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + && !value.starts_with('-') + && !value.ends_with('-') +} + +fn valid_github_repository(value: &str) -> bool { + (1..=100).contains(&value.len()) + && value != "." + && value != ".." + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +async fn read_bounded( + reader: impl AsyncRead + Unpin, + max_bytes: usize, +) -> Result, std::io::Error> { + let mut bytes = Vec::new(); + reader + .take((max_bytes + 1) as u64) + .read_to_end(&mut bytes) + .await?; + if bytes.len() > max_bytes { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "GitHub response exceeded the size limit", + )); + } + Ok(bytes) +} + +fn first_safe_line(stderr: &[u8]) -> Option<&str> { + std::str::from_utf8(stderr) + .ok()? + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.chars().any(char::is_control)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn locator() -> GithubPullRequestLocator { + parse_github_pull_request_url("https://github.com/Block/Buzz/pull/803") + .expect("valid PR URL") + } + + #[test] + fn github_pr_urls_are_strictly_validated_and_normalized() { + assert_eq!( + parse_github_pull_request_url("https://github.com/block/buzz/pull/00803/") + .expect("valid PR URL") + .url, + "https://github.com/block/buzz/pull/803" + ); + for invalid in [ + "http://github.com/block/buzz/pull/803", + "https://github.com.evil.example/block/buzz/pull/803", + "https://user@github.com/block/buzz/pull/803", + "https://github.com/block/buzz/issues/803", + "https://github.com/block/buzz/pull/803/files", + "https://github.com/block/buzz/pull/0", + "https://github.com/block/buzz/pull/803?diff=split", + " https://github.com/block/buzz/pull/803", + "https://github.com/bl%6fck/buzz/pull/803", + ] { + assert!( + parse_github_pull_request_url(invalid).is_err(), + "accepted {invalid}" + ); + } + } + + #[test] + fn github_response_is_metadata_only_sorted_and_capped() { + let comments = (0..20) + .map(|index| { + serde_json::json!({ + "createdAt": format!("2026-08-27T12:{index:02}:00Z"), + "author": { + "login": "commenter", + "avatarUrl": "https://avatars.githubusercontent.com/u/456?v=4" + }, + "url": format!("https://github.com/block/buzz/pull/803#issuecomment-{index}") + }) + }) + .collect::>(); + let output = serde_json::json!({ + "data": {"repository": {"pullRequest": { + "title": "Collections", + "state": "MERGED", + "author": { + "login": "author", + "avatarUrl": "https://avatars.githubusercontent.com/u/123?v=4" + }, + "updatedAt": "2026-08-27T13:00:00Z", + "url": "https://github.com/block/buzz/pull/803", + "mergedAt": "2026-08-27T12:59:00Z", + "mergedBy": { + "login": "merger", + "avatarUrl": "https://github.com/merger.png" + }, + "reviews": {"nodes": [{ + "state": "APPROVED", + "submittedAt": "2026-08-27T12:58:00Z", + "author": { + "login": "reviewer", + "avatarUrl": "https://example.com/reviewer.png" + } + }]}, + "comments": {"nodes": comments} + }}} + }); + let parsed = parse_github_pull_request(output.to_string().as_bytes(), &locator()) + .expect("valid response"); + assert_eq!(parsed.state, "merged"); + assert_eq!(parsed.author.as_deref(), Some("author")); + assert_eq!( + parsed.author_avatar_url.as_deref(), + Some("https://avatars.githubusercontent.com/u/123?v=4") + ); + assert_eq!(parsed.activity.len(), MAX_GITHUB_ACTIVITY); + assert_eq!(parsed.activity[0].kind, "merge"); + assert_eq!( + parsed.activity[0].author_avatar_url.as_deref(), + Some("https://github.com/merger.png") + ); + assert!(parsed.activity.iter().any(|activity| { + activity.kind == "comment" + && activity.author_avatar_url.as_deref() + == Some("https://avatars.githubusercontent.com/u/456?v=4") + })); + let serialized = serde_json::to_value(parsed).expect("serialize PR activity"); + assert!(serialized["activity"] + .as_array() + .expect("activity array") + .iter() + .all(|activity| activity.get("body").is_none())); + } + + #[test] + fn github_avatar_urls_are_credential_free_and_host_allowlisted() { + assert_eq!( + normalize_github_avatar_url("https://avatars.githubusercontent.com/u/123?v=4") + .as_deref(), + Some("https://avatars.githubusercontent.com/u/123?v=4") + ); + assert_eq!( + normalize_github_avatar_url("https://github.com/octocat.png").as_deref(), + Some("https://github.com/octocat.png") + ); + for invalid in [ + "http://avatars.githubusercontent.com/u/123", + "https://user@avatars.githubusercontent.com/u/123", + "https://avatars.githubusercontent.com.evil.example/u/123", + "https://example.com/avatar.png", + "https://github.com/", + " https://github.com/octocat.png", + ] { + assert_eq!( + normalize_github_avatar_url(invalid), + None, + "accepted {invalid}" + ); + } + } + + #[test] + fn github_response_must_match_the_requested_url() { + let output = serde_json::json!({ + "data": {"repository": {"pullRequest": { + "title": "Other", + "state": "OPEN", + "author": null, + "updatedAt": "2026-08-27T13:00:00Z", + "url": "https://github.com/block/buzz/pull/804", + "mergedAt": null, + "mergedBy": null, + "reviews": {"nodes": []}, + "comments": {"nodes": []} + }}} + }); + assert!(parse_github_pull_request(output.to_string().as_bytes(), &locator()).is_err()); + } + + #[tokio::test] + async fn github_tool_output_is_bounded() { + let error = read_bounded(&b"12345"[..], 4) + .await + .expect_err("oversized output should fail"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 7cb2d8e3b8..3711334891 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -17,6 +17,8 @@ mod channel_templates; mod channel_window; mod channels; mod clipboard; +mod collections; +mod collections_github; mod dms; mod engrams; mod export_util; @@ -87,6 +89,8 @@ pub use channel_templates::*; pub use channel_window::*; pub use channels::*; pub use clipboard::*; +pub use collections::*; +pub use collections_github::*; pub use dms::*; pub use engrams::*; pub use global_agent_config::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 613040b809..4ed90d80e6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -551,6 +551,17 @@ pub fn run() { transfer_builderlab_community, title_bar_double_click, get_identity, + list_collections, + get_collection, + create_collection, + set_collection_icon, + set_collection_name, + delete_collection, + add_collection_member, + remove_collection_member, + discover_collection_calendar_links, + discover_collection_calendar_activity, + resolve_collection_github_pull_request, get_nsec, generate_backup_passphrase, create_ncryptsec_backup, diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index 9fc14736c7..af7950b777 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -9,7 +9,8 @@ export type AppView = | "agents" | "workflows" | "pulse" - | "projects"; + | "projects" + | "collections"; const WINDOW_DRAG_HANDLE_HEIGHT = 44; const TAURI_DRAG_REGION_ATTR = "data-tauri-drag-region"; @@ -254,6 +255,13 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/collections" || pathname.startsWith("/collections/")) { + return { + selectedChannelId: null, + selectedView: "collections", + }; + } + if (pathname === "/pulse") { return { selectedChannelId: null, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index 2bc2c8ddb6..266644c627 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -10,11 +10,13 @@ import { Route as settingsRouteImport } from "./routes/settings"; import { Route as remindersRouteImport } from "./routes/reminders"; import { Route as pulseRouteImport } from "./routes/pulse"; import { Route as projectsRouteImport } from "./routes/projects"; +import { Route as collectionsRouteImport } from "./routes/collections"; import { Route as agentsRouteImport } from "./routes/agents"; import { Route as indexRouteImport } from "./routes/index"; import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId"; import { Route as projectsDotprojectIdRouteImport } from "./routes/projects.$projectId"; import { Route as messagesDotnewRouteImport } from "./routes/messages.new"; +import { Route as collectionsDotcollectionIdRouteImport } from "./routes/collections.$collectionId"; import { Route as channelsDotchannelIdRouteImport } from "./routes/channels.$channelId"; import { Route as channelsDotchannelIdDotpostsDotpostIdRouteImport } from "./routes/channels.$channelId.posts.$postId"; @@ -43,6 +45,11 @@ const projectsRoute = projectsRouteImport.update({ path: "/projects", getParentRoute: () => rootRouteImport, } as any); +const collectionsRoute = collectionsRouteImport.update({ + id: "/collections", + path: "/collections", + getParentRoute: () => rootRouteImport, +} as any); const agentsRoute = agentsRouteImport.update({ id: "/agents", path: "/agents", @@ -68,6 +75,12 @@ const messagesDotnewRoute = messagesDotnewRouteImport.update({ path: "/messages/new", getParentRoute: () => rootRouteImport, } as any); +const collectionsDotcollectionIdRoute = + collectionsDotcollectionIdRouteImport.update({ + id: "/collections/$collectionId", + path: "/collections/$collectionId", + getParentRoute: () => rootRouteImport, + } as any); const channelsDotchannelIdRoute = channelsDotchannelIdRouteImport.update({ id: "/channels/$channelId", path: "/channels/$channelId", @@ -83,12 +96,14 @@ const channelsDotchannelIdDotpostsDotpostIdRoute = export interface FileRoutesByFullPath { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/collections": typeof collectionsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; + "/collections/$collectionId": typeof collectionsDotcollectionIdRoute; "/messages/new": typeof messagesDotnewRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; "/workflows/$workflowId": typeof workflowsDotworkflowIdRoute; @@ -97,12 +112,14 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/collections": typeof collectionsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; + "/collections/$collectionId": typeof collectionsDotcollectionIdRoute; "/messages/new": typeof messagesDotnewRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; "/workflows/$workflowId": typeof workflowsDotworkflowIdRoute; @@ -112,12 +129,14 @@ export interface FileRoutesById { __root__: typeof rootRouteImport; "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/collections": typeof collectionsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; + "/collections/$collectionId": typeof collectionsDotcollectionIdRoute; "/messages/new": typeof messagesDotnewRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; "/workflows/$workflowId": typeof workflowsDotworkflowIdRoute; @@ -128,12 +147,14 @@ export interface FileRouteTypes { fullPaths: | "/" | "/agents" + | "/collections" | "/projects" | "/pulse" | "/reminders" | "/settings" | "/workflows" | "/channels/$channelId" + | "/collections/$collectionId" | "/messages/new" | "/projects/$projectId" | "/workflows/$workflowId" @@ -142,12 +163,14 @@ export interface FileRouteTypes { to: | "/" | "/agents" + | "/collections" | "/projects" | "/pulse" | "/reminders" | "/settings" | "/workflows" | "/channels/$channelId" + | "/collections/$collectionId" | "/messages/new" | "/projects/$projectId" | "/workflows/$workflowId" @@ -156,12 +179,14 @@ export interface FileRouteTypes { | "__root__" | "/" | "/agents" + | "/collections" | "/projects" | "/pulse" | "/reminders" | "/settings" | "/workflows" | "/channels/$channelId" + | "/collections/$collectionId" | "/messages/new" | "/projects/$projectId" | "/workflows/$workflowId" @@ -171,12 +196,14 @@ export interface FileRouteTypes { export interface RootRouteChildren { indexRoute: typeof indexRoute; agentsRoute: typeof agentsRoute; + collectionsRoute: typeof collectionsRoute; projectsRoute: typeof projectsRoute; pulseRoute: typeof pulseRoute; remindersRoute: typeof remindersRoute; settingsRoute: typeof settingsRoute; workflowsRoute: typeof workflowsRoute; channelsDotchannelIdRoute: typeof channelsDotchannelIdRoute; + collectionsDotcollectionIdRoute: typeof collectionsDotcollectionIdRoute; messagesDotnewRoute: typeof messagesDotnewRoute; projectsDotprojectIdRoute: typeof projectsDotprojectIdRoute; workflowsDotworkflowIdRoute: typeof workflowsDotworkflowIdRoute; @@ -220,6 +247,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof projectsRouteImport; parentRoute: typeof rootRouteImport; }; + "/collections": { + id: "/collections"; + path: "/collections"; + fullPath: "/collections"; + preLoaderRoute: typeof collectionsRouteImport; + parentRoute: typeof rootRouteImport; + }; "/agents": { id: "/agents"; path: "/agents"; @@ -255,6 +289,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof messagesDotnewRouteImport; parentRoute: typeof rootRouteImport; }; + "/collections/$collectionId": { + id: "/collections/$collectionId"; + path: "/collections/$collectionId"; + fullPath: "/collections/$collectionId"; + preLoaderRoute: typeof collectionsDotcollectionIdRouteImport; + parentRoute: typeof rootRouteImport; + }; "/channels/$channelId": { id: "/channels/$channelId"; path: "/channels/$channelId"; @@ -275,12 +316,14 @@ declare module "@tanstack/react-router" { const rootRouteChildren: RootRouteChildren = { indexRoute: indexRoute, agentsRoute: agentsRoute, + collectionsRoute: collectionsRoute, projectsRoute: projectsRoute, pulseRoute: pulseRoute, remindersRoute: remindersRoute, settingsRoute: settingsRoute, workflowsRoute: workflowsRoute, channelsDotchannelIdRoute: channelsDotchannelIdRoute, + collectionsDotcollectionIdRoute: collectionsDotcollectionIdRoute, messagesDotnewRoute: messagesDotnewRoute, projectsDotprojectIdRoute: projectsDotprojectIdRoute, workflowsDotworkflowIdRoute: workflowsDotworkflowIdRoute, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index f5c6938e11..1ef9ba7ccd 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -10,6 +10,8 @@ export const routes = rootRoute("root.tsx", [ route("/workflows/$workflowId", "workflows.$workflowId.tsx"), route("/projects", "projects.tsx"), route("/projects/$projectId", "projects.$projectId.tsx"), + route("/collections", "collections.tsx"), + route("/collections/$collectionId", "collections.$collectionId.tsx"), route("/messages/new", "messages.new.tsx"), route("/channels/$channelId", "channels.$channelId.tsx"), route( diff --git a/desktop/src/app/routes/collections.$collectionId.tsx b/desktop/src/app/routes/collections.$collectionId.tsx new file mode 100644 index 0000000000..07defa280e --- /dev/null +++ b/desktop/src/app/routes/collections.$collectionId.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +import { usePreviewFeatureWarning } from "@/shared/features"; + +const CollectionDetailScreen = React.lazy(async () => { + const module = await import( + "@/features/collections/ui/CollectionDetailScreen" + ); + return { default: module.CollectionDetailScreen }; +}); + +export const Route = createFileRoute("/collections/$collectionId")({ + component: CollectionDetailRouteComponent, +}); + +function CollectionDetailRouteComponent() { + usePreviewFeatureWarning("collections"); + const { collectionId } = Route.useParams(); + return ( + Loading collection…}> + + + ); +} diff --git a/desktop/src/app/routes/collections.tsx b/desktop/src/app/routes/collections.tsx new file mode 100644 index 0000000000..6f99793583 --- /dev/null +++ b/desktop/src/app/routes/collections.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +import { usePreviewFeatureWarning } from "@/shared/features"; + +const CollectionsScreen = React.lazy(async () => { + const module = await import("@/features/collections/ui/CollectionsScreen"); + return { default: module.CollectionsScreen }; +}); + +export const Route = createFileRoute("/collections")({ + component: CollectionsRouteComponent, +}); + +function CollectionsRouteComponent() { + usePreviewFeatureWarning("collections"); + return ( + Loading collections…}> + + + ); +} diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 44e4d891dc..8e5378922a 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -2,6 +2,8 @@ import { LogIn, SquareTerminal } from "lucide-react"; import type * as React from "react"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; +import { AddToCollection } from "@/features/collections/ui/AddToCollection"; +import { CollectionMembershipBadges } from "@/features/collections/ui/CollectionMembershipBadges"; import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel"; import type { ActiveDmHeaderParticipant } from "@/features/channels/useActiveChannelHeader"; import { getChannelDescription } from "@/features/channels/lib/channelDescription"; @@ -16,6 +18,7 @@ import { } from "@/features/profile/ui/ProfileAvatarWithStatus"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { Button } from "@/shared/ui/button"; +import { FeatureGate } from "@/shared/features"; import type { Channel, PresenceStatus } from "@/shared/api/types"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { @@ -82,6 +85,19 @@ export function ChannelScreenHeader({ onJoinChannel; const terminalPanel = useTerminalPanel(); + const collectionButton = activeChannel ? ( + +
+ + +
+
+ ) : null; const terminalButton = activeChannel ? ( + + ); +} diff --git a/desktop/src/features/collections/ui/AddToCollection.tsx b/desktop/src/features/collections/ui/AddToCollection.tsx new file mode 100644 index 0000000000..547a8acbf2 --- /dev/null +++ b/desktop/src/features/collections/ui/AddToCollection.tsx @@ -0,0 +1,183 @@ +import { Check, FolderPlus } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { useCollectionMutations, useCollectionsQuery } from "../hooks"; +import { + collectionReferenceKey, + useCollectionMembershipIndex, +} from "../membership"; +import type { Collection, CollectionReference } from "../types"; +import { useCollectionScope } from "../useCollectionScope"; +import { Button } from "@/shared/ui/button"; +import { CollectionGlyph } from "./CollectionGlyph"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/shared/ui/dialog"; + +type AddToCollectionProps = { + label?: string | null; + onOpenChange?: (open: boolean) => void; + open?: boolean; + reference: CollectionReference; + trigger?: React.ReactNode | false; +}; + +/** Reusable item-surface action for adding one typed reference to a collection. */ +export function AddToCollection({ + label, + onOpenChange, + open: controlledOpen, + reference, + trigger, +}: AddToCollectionProps) { + const scope = useCollectionScope(); + const collections = useCollectionsQuery(scope); + const { addMember } = useCollectionMutations(scope); + const [internalOpen, setInternalOpen] = React.useState(false); + const open = controlledOpen ?? internalOpen; + const setOpen = (nextOpen: boolean) => { + if (controlledOpen === undefined) setInternalOpen(nextOpen); + onOpenChange?.(nextOpen); + }; + + const add = async (collectionId: string, collectionName: string) => { + try { + await addMember.mutateAsync({ collectionId, label, reference }); + toast.success(`Added to ${collectionName}`); + setOpen(false); + } catch (error) { + const message = + error instanceof Error + ? error.message + : typeof error === "string" && error.trim() + ? error + : "Could not add to collection"; + console.error("Could not add to collection", error); + toast.error(message); + } + }; + + return ( + + {trigger !== false ? ( + + {trigger ?? ( + + )} + + ) : null} + + + Add to collection + + Choose where this {reference.type} belongs. + + +
+ {open ? ( + + ) : null} + {!collections.isPending && (collections.data?.length ?? 0) === 0 ? ( +

+ {collections.isError + ? "Could not load collections." + : "Create a collection first."} +

+ ) : null} + {collections.isError ? ( + + ) : null} +
+
+
+ ); +} + +function CollectionPickerRows({ + collections, + isAdding, + onAdd, + reference, +}: { + collections: readonly Collection[]; + isAdding: boolean; + onAdd: (collectionId: string, collectionName: string) => Promise; + reference: CollectionReference; +}) { + const scope = useCollectionScope(); + const { removeMember } = useCollectionMutations(scope); + const memberships = useCollectionMembershipIndex().get( + collectionReferenceKey(reference), + ); + const membershipsByCollection = new Map( + (memberships ?? []).map((membership) => [ + membership.collection.id, + membership, + ]), + ); + + const remove = async ( + collectionId: string, + collectionName: string, + memberId: string, + ) => { + if (!window.confirm(`Remove from Collection “${collectionName}”?`)) return; + try { + await removeMember.mutateAsync({ collectionId, memberId }); + toast.success(`Removed from ${collectionName}`); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Could not remove from collection", + ); + } + }; + + return collections.map((collection) => { + const membership = membershipsByCollection.get(collection.id); + return ( + + ); + }); +} diff --git a/desktop/src/features/collections/ui/CollectionDetailScreen.tsx b/desktop/src/features/collections/ui/CollectionDetailScreen.tsx new file mode 100644 index 0000000000..5ae187af9d --- /dev/null +++ b/desktop/src/features/collections/ui/CollectionDetailScreen.tsx @@ -0,0 +1,1034 @@ +import { useNavigate } from "@tanstack/react-router"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { + ArrowLeft, + Bot, + CalendarDays, + Ellipsis, + ExternalLink, + FileText, + GitBranch, + GitPullRequest, + Hash, + Link2, + ListTodo, + MessageSquare, + MessagesSquare, + Plus, + Search, + StickyNote, + UserRound, +} from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { useOpenEntityLink } from "@/shared/ui/markdown/entityLinks"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { + COLLECTION_REFERENCE_TYPES, + collectionMemberMatches, + collectionReferenceIdentity, +} from "../memberDisplay"; +import { collectionMemberNavigationTarget } from "../memberNavigation"; +import { + useCollectionMutations, + useCollectionQuery, + useDerivedCollectionLinks, +} from "../hooks"; +import { + type DerivedCollectionActivity, + projectCollectionActivity, +} from "../derivedLinks"; +import type { + CollectionMember, + CollectionReference, + CollectionReferenceType, +} from "../types"; +import { useCollectionScope } from "../useCollectionScope"; +import { AddCollectionReferenceForm } from "./AddCollectionReferenceForm"; +import { CollectionGlyph } from "./CollectionGlyph"; +import { CollectionIconDialog } from "./CollectionIconDialog"; + +export function CollectionDetailScreen({ + collectionId, +}: { + collectionId: string; +}) { + const scope = useCollectionScope(); + const collectionQuery = useCollectionQuery(scope, collectionId); + const { addMember, remove, removeMember, setIcon } = + useCollectionMutations(scope); + const navigate = useNavigate(); + const { goChannel } = useAppNavigation(); + const openEntityLink = useOpenEntityLink(); + const [search, setSearch] = React.useState(""); + const [addOpen, setAddOpen] = React.useState(false); + const [iconOpen, setIconOpen] = React.useState(false); + const [selectedPerson, setSelectedPerson] = React.useState( + null, + ); + const [activeTab, setActiveTab] = React.useState< + "overview" | "activity" | "sources" + >("overview"); + const [type, setType] = React.useState( + "all", + ); + const data = collectionQuery.data; + const derived = useDerivedCollectionLinks(data?.members ?? []); + const participantIdentities = [ + ...new Set( + derived.links.flatMap((activity) => + activity.actorIdentity ? [activity.actorIdentity] : [], + ), + ), + ]; + const actorPubkeys = participantIdentities.flatMap((identity) => + identity.startsWith("buzz:") ? [identity.slice("buzz:".length)] : [], + ); + const profilesQuery = useUsersBatchQuery(actorPubkeys); + const profiles = profilesQuery.data?.profiles ?? {}; + const peopleIdentities = participantIdentities.filter( + (identity) => + !identity.startsWith("buzz:") || + !profiles[identity.slice("buzz:".length)]?.isAgent, + ); + const members = (data?.members ?? []).filter((member) => + collectionMemberMatches(member, search, type), + ); + const pullRequests = derived.links.filter( + (activity) => activity.activityType === "github-pr", + ); + const projectedActivity = projectCollectionActivity( + derived.links, + Object.fromEntries( + actorPubkeys.flatMap((pubkey) => { + const profile = profiles[pubkey.toLocaleLowerCase()]; + return profile + ? [[pubkey.toLocaleLowerCase(), profile.isAgent] as const] + : []; + }), + ), + ); + const filteredActivity = selectedPerson + ? projectedActivity.filter( + (activity) => activity.actorIdentity === selectedPerson, + ) + : projectedActivity; + const sourceGroups = [ + { + label: "Channels", + members: members.filter((member) => member.reference.type === "channel"), + }, + { + label: "Code", + members: members.filter((member) => + ["repository", "task"].includes(member.reference.type), + ), + }, + { + label: "Conversations", + members: members.filter((member) => + ["thread", "message", "note"].includes(member.reference.type), + ), + }, + { + label: "External", + members: members.filter((member) => member.reference.type === "external"), + }, + ]; + + const addReference = async ( + reference: CollectionReference, + label: string | null, + ) => { + try { + await addMember.mutateAsync({ collectionId, label, reference }); + toast.success(`${reference.type} added`); + setAddOpen(false); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Could not add reference", + ); + throw error; + } + }; + + const removeSource = async ( + memberId: string, + label: string, + discoveredActivity: boolean, + ) => { + const prompt = discoveredActivity + ? `Remove source “${label}” from this Collection? Related discovered activity will disappear.` + : `Remove “${label}” from this Collection?`; + if (!window.confirm(prompt)) return; + try { + await removeMember.mutateAsync({ collectionId, memberId }); + toast.success("Source removed from Collection"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Could not remove source", + ); + } + }; + + const sourceDetails = (memberId: string) => { + const member = data?.members.find((candidate) => candidate.id === memberId); + return { + label: member + ? member.label || collectionReferenceIdentity(member.reference) + : "source", + memberId, + }; + }; + + const openMember = (reference: CollectionReference) => { + const target = collectionMemberNavigationTarget(reference); + if (!target || target.kind === "external") return; + if (target.kind === "channel") { + void goChannel(target.channelId); + } else if (target.kind === "message") { + void goChannel(target.channelId, { + messageId: target.messageId, + threadRootId: target.threadRootId, + }); + } else { + openEntityLink(target.entity); + } + }; + + if (collectionQuery.isPending) { + return ( +
+ Loading collection… +
+ ); + } + if (collectionQuery.isError && !data) { + return ( +
+

Could not load this collection.

+ +
+ ); + } + if (!data) return null; + + return ( +
+
+
+
+ +

+ + {data.collection.name} +

+

+ + {" · "} + {derived.links.length}{" "} + {derived.links.length === 1 + ? "discovered item" + : "discovered items"} +

+
+
+ + + + + + + { + void (async () => { + if (!window.confirm(`Delete “${data.collection.name}”?`)) + return; + try { + await remove.mutateAsync(collectionId); + await navigate({ to: "/collections" }); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Could not delete collection", + ); + } + })(); + }} + > + Delete Collection + + + +
+
+ + {collectionQuery.isError ? ( +
+ Could not refresh this collection. + +
+ ) : null} + + + setActiveTab(value as "overview" | "activity" | "sources") + } + value={activeTab} + > + + Overview + Activity + Sources + + + + {activeTab === "sources" ? ( +
+
+ + setSearch(event.target.value)} + placeholder="Search this collection" + value={search} + /> +
+ +
+ ) : null} + + {activeTab === "overview" ? ( + <> +
+
+

Quick navigation

+

+ Explicit channel and repository sources. +

+
+ {data.members + .filter((member) => + ["channel", "repository"].includes(member.reference.type), + ) + .map((member) => { + const target = collectionMemberNavigationTarget( + member.reference, + ); + const label = + member.label || + collectionReferenceIdentity(member.reference); + const canOpen = target !== null; + return ( +
+ + + removeSource(member.id, label, false) + } + /> +
+ ); + })} + {!data.members.some((member) => + ["channel", "repository"].includes(member.reference.type), + ) ? ( + + Add a channel or repository to create shortcuts. + + ) : null} +
+
+ +
+

Briefing

+

+ No sourced briefing is available yet. +

+
+
+ + {peopleIdentities.length > 0 ? ( +
+
+

People

+

+ Participants found in current sourced activity. +

+
+
+ {peopleIdentities.map((identity) => { + const pubkey = identity.startsWith("buzz:") + ? identity.slice("buzz:".length) + : null; + const profile = pubkey ? profiles[pubkey] : undefined; + const providerActivity = derived.links.find( + (activity) => activity.actorIdentity === identity, + ); + const label = + profile?.displayName ?? + profile?.name ?? + providerActivity?.actorLabel ?? + identity.slice(identity.indexOf(":") + 1); + const ActorIcon = profile?.isAgent ? Bot : UserRound; + return ( + + ); + })} + {selectedPerson ? ( + + ) : null} +
+
+ ) : null} + + {derived.warnings.map((warning) => ( +
+ {warning.warningType === "calendar" + ? "Calendar discovery" + : warning.warningType === "github-pr" + ? "GitHub PR resolution" + : "Source activity"}{" "} + failed for {warning.sourceLabel}: {warning.message} +
+ ))} + + {pullRequests.length > 0 ? ( +
+
+

Pull requests

+

+ Pull requests mentioned by explicit thread and message + sources. +

+
+
+ {pullRequests.map((activity) => ( + {}} + onRemoveSource={() => { + const source = sourceDetails(activity.sourceMemberId); + return removeSource( + source.memberId, + source.label, + true, + ); + }} + /> + ))} +
+
+ ) : null} + + ) : null} + + {activeTab !== "sources" ? ( +
+
+

+ {activeTab === "overview" ? "Recent activity" : "Activity"} +

+

+ Recent conversations and live context derived from your sources. +

+
+
+ {(activeTab === "overview" + ? filteredActivity.slice(0, 5) + : filteredActivity + ).map((activity) => ( + { + if (!activity.channelId || !activity.eventId) return; + void goChannel(activity.channelId, { + messageId: activity.eventId, + threadRootId: activity.threadRootId ?? null, + }); + }} + onRemoveSource={() => { + const source = sourceDetails(activity.sourceMemberId); + return removeSource(source.memberId, source.label, true); + }} + /> + ))} + {filteredActivity.length === 0 ? ( +
+ {selectedPerson + ? "No activity from this person was found." + : derived.isFetching + ? "Loading activity…" + : data.members.length === 0 + ? "Add a source to discover collection activity." + : "No recent activity was found across linked sources."} +
+ ) : null} +
+ {activeTab === "overview" && filteredActivity.length > 5 ? ( + + ) : null} +
+ ) : null} + + {activeTab === "sources" ? ( +
+
+

Sources

+

+ Explicit context used to assemble this collection. +

+
+
+ {sourceGroups.map((group) => + group.members.length > 0 ? ( +
+

+ {group.label} +

+
+ {group.members.map((member) => { + const target = collectionMemberNavigationTarget( + member.reference, + ); + return ( + openMember(member.reference)} + onRemove={() => + removeSource( + member.id, + member.label || + collectionReferenceIdentity(member.reference), + false, + ) + } + /> + ); + })} +
+
+ ) : null, + )} + {members.length === 0 ? ( +
+ {data.members.length === 0 + ? "No sources in this collection yet." + : "No items match these filters."} +
+ ) : null} +
+
+ ) : null} + + + + + Add a source + + Paste an external link, or use advanced coordinates when an item + cannot be added from its Buzz screen. + + + + + + { + try { + await setIcon.mutateAsync({ collectionId, icon }); + toast.success("Collection icon updated"); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Could not update Collection icon", + ); + throw error; + } + }} + open={iconOpen} + /> +
+
+ ); +} + +function DerivedCollectionActivityRow({ + activity, + actorAvatarUrl, + actorLabel, + onOpenMessage, + onRemoveSource, +}: { + activity: DerivedCollectionActivity; + actorAvatarUrl?: string; + actorLabel?: string; + onOpenMessage: () => void; + onRemoveSource: () => Promise; +}) { + const isMessage = activity.activityType === "channel-message"; + const isDerived = !isMessage || activity.derivedFromSource === true; + const isDocumentActivity = + activity.activityType === "document-edit" || + activity.activityType === "document-comment"; + const isPullRequest = activity.activityType === "github-pr"; + const isPullRequestActivity = activity.activityType === "github-pr-activity"; + const identity = isMessage + ? activity.eventId + : isDocumentActivity || isPullRequestActivity + ? `${activity.url}:${activity.activityType}:${activity.createdAt}` + : activity.url; + const timestamp = activity.createdAt + ? new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(activity.createdAt * 1_000)) + : null; + const content = ( +
+
+ + {isDerived ? ( + + + + ) : null} + {activity.label} +
+ {isDocumentActivity ? ( +
+ {activity.actorLabel}{" "} + {activity.activityType === "document-edit" + ? "edited" + : "commented on"}{" "} + {activity.label} + {timestamp ? ` · ${timestamp}` : null} +
+ ) : isPullRequestActivity ? ( +
+ {activity.actorLabel ?? "Unknown GitHub actor"}{" "} + {activity.kind.replace(/^PR /u, "")} + {activity.stateLabel ? ` · ${activity.stateLabel}` : null} + {timestamp ? ` · ${timestamp}` : null} +
+ ) : isPullRequest ? ( +
+ {activity.stateLabel ? ( + + {activity.stateLabel} + + ) : ( + "Mentioned PR" + )} + {activity.actorLabel ? ` by ${activity.actorLabel}` : null} + {timestamp ? ` · ${timestamp}` : null} +
+ ) : isMessage ? ( +
+ {actorLabel ?? "Unknown Buzz user"} + {timestamp ? ` · ${timestamp}` : null} +
+ ) : timestamp ? ( +
{timestamp}
+ ) : null} + {activity.provenanceLabel ? ( +
+ From {activity.provenanceLabel} +
+ ) : null} + {activity.url ? ( +
+ {activity.url} +
+ ) : null} +
+ ); + return ( +
+ {actorLabel ? ( + + ) : null} + {isMessage ? ( + + ) : ( + content + )} + {!isMessage ? ( + + ) : null} + +
+ ); +} + +function ActivityTypeGlyph({ + activity, +}: { + activity: DerivedCollectionActivity; +}) { + const Icon = + activity.activityType === "github-pr" || + activity.activityType === "github-pr-activity" + ? GitPullRequest + : activity.activityType === "calendar-document" + ? CalendarDays + : activity.activityType === "channel-message" + ? activity.kind === "thread" + ? MessagesSquare + : MessageSquare + : FileText; + return ( + + ); +} + +function CollectionMemberRow({ + canOpen, + isRemoving, + member, + onOpen, + onRemove, +}: { + canOpen: boolean; + isRemoving: boolean; + member: CollectionMember; + onOpen: () => void; + onRemove: () => Promise; +}) { + const identity = collectionReferenceIdentity(member.reference); + const label = member.label || identity; + const content = ( +
+ + {label} + {member.reference.type === "external" ? ( + + {new URL(member.reference.url).hostname} + + ) : null} +
+ ); + + return ( +
+
+ {canOpen && member.reference.type !== "external" ? ( + + ) : ( + content + )} +
+ Details + {identity} +
+
+ {member.reference.type === "external" ? ( + + ) : null} + +
+ ); +} + +function SourceTypeGlyph({ type }: { type: CollectionReferenceType }) { + const Icon = + type === "channel" + ? Hash + : type === "repository" + ? GitBranch + : type === "task" + ? ListTodo + : type === "thread" + ? MessagesSquare + : type === "message" + ? MessageSquare + : type === "note" + ? StickyNote + : ExternalLink; + return ( + + ); +} + +function CollectionRowActions({ + derived = false, + disabled = false, + label, + onRemove, +}: { + derived?: boolean; + disabled?: boolean; + label: string; + onRemove: () => Promise; +}) { + const removeLabel = derived + ? "Remove source from Collection" + : "Remove from Collection"; + return ( + + + + + + void onRemove()} + > + {removeLabel} + + + + ); +} diff --git a/desktop/src/features/collections/ui/CollectionGlyph.tsx b/desktop/src/features/collections/ui/CollectionGlyph.tsx new file mode 100644 index 0000000000..6d0588f869 --- /dev/null +++ b/desktop/src/features/collections/ui/CollectionGlyph.tsx @@ -0,0 +1,19 @@ +import { Folder } from "lucide-react"; + +import type { Collection } from "../types"; + +export function CollectionGlyph({ + collection, + className, +}: { + collection: Pick; + className?: string; +}) { + return collection.icon ? ( + + ) : ( +