mirror of
https://github.com/block/buzz.git
synced 2026-11-08 02:11:36 +00:00
feat(cli): manage sidebar sections with buzz sidebar sections
Sidebar sections were desktop-only: they live in the encrypted kind:30078 `channel-sections` preference, and the CLI had no way to read or write it. Agents that mirror a team's channels could create the channels but not group them. Add `buzz sidebar sections list|create|update|delete|reorder|move|unassign`. The commands port desktop's per-field LWW register document (channelSectionsSync.ts / sidebarLwwMap.ts) so CLI writes merge with desktop edits field by field, and they import the flat format mobile writes. Each command is one head read and one publish, so a multi-channel `move` lands atomically. A head the CLI cannot decrypt, verify, or decode is never overwritten, and a dominated write exits 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Blake McAnally <bmcanally@squareup.com>
This commit is contained in:
@@ -140,6 +140,11 @@ buzz messages vote --event <event-id> --direction up
|
||||
buzz canvas get --channel <uuid>
|
||||
buzz canvas set --channel <uuid> --content "# Welcome"
|
||||
|
||||
# Sidebar sections (your own sidebar, synced with Buzz desktop)
|
||||
buzz sidebar sections create --name "Engineering" --icon "🛠"
|
||||
buzz sidebar sections move --section "Engineering" --channel <uuid> --channel <uuid>
|
||||
buzz sidebar sections list
|
||||
|
||||
# Agent Memory (NIP-AE)
|
||||
buzz mem ls
|
||||
buzz mem get <slug>
|
||||
@@ -156,6 +161,11 @@ buzz repos protect remove --id my-repo --ref refs/heads/main
|
||||
buzz channels list | jq '.[].name'
|
||||
```
|
||||
|
||||
`sidebar sections` edits the signing identity's own sidebar. Each command is
|
||||
one read and one publish, so a multi-channel `move` lands together. `--section`
|
||||
takes a section id or a unique section name. A repeated command prints
|
||||
`"message": "unchanged"` and publishes nothing.
|
||||
|
||||
`protect set` replaces every existing rule for the exact ref pattern. Any
|
||||
constraint omitted from the command is removed. `protect list` reports malformed
|
||||
stored rules in `validation_error` so an owner can remove and repair them.
|
||||
@@ -196,6 +206,13 @@ The table below mirrors that tree for readers who are not at a terminal.
|
||||
| | `remove-member` | Remove a member |
|
||||
| `canvas` | `get` | Get channel canvas |
|
||||
| | `set` | Set channel canvas |
|
||||
| `sidebar` | `sections list` | List your sections and their channels |
|
||||
| | `sections create` | Create a section at the end of your sidebar |
|
||||
| | `sections update` | Rename a section or change its icon |
|
||||
| | `sections delete` | Delete a section |
|
||||
| | `sections reorder` | Put sections in order |
|
||||
| | `sections move` | Move one or more channels into a section |
|
||||
| | `sections unassign` | Return channels to the default channel list |
|
||||
| `reactions` | `add` | React to a message |
|
||||
| | `remove` | Remove a reaction |
|
||||
| | `get` | List reactions |
|
||||
|
||||
@@ -25,6 +25,8 @@ pub(crate) mod read_state;
|
||||
mod repo_default_branch;
|
||||
pub mod repos;
|
||||
pub(crate) mod sent_from_thread;
|
||||
pub mod sidebar;
|
||||
mod sidebar_doc;
|
||||
pub mod social;
|
||||
pub mod upload;
|
||||
pub mod users;
|
||||
|
||||
643
crates/buzz-cli/src/commands/sidebar.rs
Normal file
643
crates/buzz-cli/src/commands/sidebar.rs
Normal file
@@ -0,0 +1,643 @@
|
||||
//! `buzz sidebar sections` — your own channel sections, the same synced
|
||||
//! preference the desktop sidebar edits (kind:30078, d=`channel-sections`,
|
||||
//! NIP-44 encrypted to yourself).
|
||||
//!
|
||||
//! Every write is one read of the current head followed by one publish, so a
|
||||
//! multi-channel `move` lands atomically. A head this CLI cannot read is never
|
||||
//! overwritten.
|
||||
|
||||
use std::time::SystemTime;
|
||||
|
||||
use buzz_core::kind::KIND_READ_STATE;
|
||||
use nostr::nips::nip44;
|
||||
use nostr::{EventBuilder, Kind, Tag, Timestamp};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::parse_write_response;
|
||||
use super::sidebar_doc::{self, Edit, Projection, Section, Stamp, Tree};
|
||||
use crate::client::BuzzClient;
|
||||
use crate::error::CliError;
|
||||
use crate::{SidebarCmd, SidebarSectionsCmd};
|
||||
|
||||
const D_TAG: &str = "channel-sections";
|
||||
/// Relay event size ceiling shared with desktop's sidebar blobs.
|
||||
const MAX_CIPHERTEXT_BYTES: usize = 65_535;
|
||||
|
||||
struct Head {
|
||||
created_at: u64,
|
||||
tree: Tree,
|
||||
}
|
||||
|
||||
fn now() -> std::time::Duration {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The relay's current sections event for this identity, decoded. An absent
|
||||
/// head is an empty tree; an unreadable one is an error, never an empty list.
|
||||
async fn fetch_head(client: &BuzzClient) -> Result<Head, CliError> {
|
||||
let me = client.keys().public_key();
|
||||
let filter = json!({
|
||||
"kinds": [KIND_READ_STATE],
|
||||
"authors": [me.to_hex()],
|
||||
"#d": [D_TAG],
|
||||
"limit": 1,
|
||||
});
|
||||
let raw = client.query(&filter).await?;
|
||||
let events: Vec<Value> = serde_json::from_str(&raw)
|
||||
.map_err(|e| CliError::Other(format!("failed to parse sidebar sections query: {e}")))?;
|
||||
let returned = events.len();
|
||||
let head = events
|
||||
.into_iter()
|
||||
.filter_map(|event| serde_json::from_value::<nostr::Event>(event).ok())
|
||||
.filter(|event| {
|
||||
event.pubkey == me && event.tags.identifier() == Some(D_TAG) && event.verify().is_ok()
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| a.id.to_hex().cmp(&b.id.to_hex()))
|
||||
});
|
||||
let Some(event) = head else {
|
||||
if returned > 0 {
|
||||
return Err(CliError::Other(
|
||||
"the relay returned a sidebar sections event that is not yours or fails verification; refusing to overwrite it".into(),
|
||||
));
|
||||
}
|
||||
return Ok(Head {
|
||||
created_at: 0,
|
||||
tree: Tree::new(),
|
||||
});
|
||||
};
|
||||
|
||||
let unreadable = |detail: String| {
|
||||
CliError::Other(format!(
|
||||
"your sidebar sections event {} is unreadable ({detail}); refusing to read or overwrite it",
|
||||
event.id.to_hex()
|
||||
))
|
||||
};
|
||||
let plaintext = nip44::decrypt(client.keys().secret_key(), &me, &event.content)
|
||||
.map_err(|e| unreadable(e.to_string()))?;
|
||||
let json: Value = serde_json::from_str(&plaintext).map_err(|e| unreadable(e.to_string()))?;
|
||||
let created_at = event.created_at.as_secs();
|
||||
let legacy_v = i64::try_from(created_at.saturating_mul(1_000)).unwrap_or(i64::MAX);
|
||||
let tree = sidebar_doc::decode(&json, legacy_v).ok_or_else(|| {
|
||||
unreadable("unsupported format; a newer client may have written it".into())
|
||||
})?;
|
||||
Ok(Head { created_at, tree })
|
||||
}
|
||||
|
||||
/// A live section by exact id, or by exact name when that name is unique.
|
||||
fn resolve<'a>(view: &'a Projection, key: &str) -> Result<&'a Section, CliError> {
|
||||
if let Some(section) = view.sections.iter().find(|s| s.id == key) {
|
||||
return Ok(section);
|
||||
}
|
||||
let key = key.trim();
|
||||
let mut named = view.sections.iter().filter(|s| s.name == key);
|
||||
match (named.next(), named.next()) {
|
||||
(Some(section), None) => Ok(section),
|
||||
(Some(_), Some(_)) => Err(CliError::Usage(format!(
|
||||
"more than one section is named \"{key}\"; pass the section id from `buzz sidebar sections list`"
|
||||
))),
|
||||
(None, _) => Err(CliError::NotFound(format!(
|
||||
"no section has the id or name \"{key}\""
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn section_name(name: &str) -> Result<String, CliError> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(CliError::Usage("section name must not be empty".into()));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
fn section_icon(icon: Option<&str>) -> Result<Option<String>, CliError> {
|
||||
match icon.map(str::trim) {
|
||||
Some("") => Err(CliError::Usage(
|
||||
"--icon must not be empty; use --clear-icon to remove an icon".into(),
|
||||
)),
|
||||
icon => Ok(icon.map(str::to_string)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel ids in the lowercase hyphenated form desktop keys assignments by.
|
||||
fn channel_ids(channels: &[String]) -> Result<Vec<String>, CliError> {
|
||||
channels
|
||||
.iter()
|
||||
.map(|channel| {
|
||||
uuid::Uuid::parse_str(channel.trim())
|
||||
.map(|id| id.hyphenated().to_string())
|
||||
.map_err(|_| CliError::Usage(format!("invalid channel UUID: {channel}")))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reads the head, applies `apply`, and publishes the result once. Returns
|
||||
/// the write response, or an `unchanged` response without publishing.
|
||||
async fn edit(
|
||||
client: &BuzzClient,
|
||||
apply: impl FnOnce(&mut Edit, &Projection) -> Result<(), CliError>,
|
||||
) -> Result<Value, CliError> {
|
||||
let head = fetch_head(client).await?;
|
||||
let view = sidebar_doc::project(&head.tree);
|
||||
let mut tree = head.tree.clone();
|
||||
let stamp = Stamp {
|
||||
now_ms: i64::try_from(now().as_millis()).unwrap_or(i64::MAX),
|
||||
dev: format!("{:016x}", rand::random::<u64>()),
|
||||
};
|
||||
apply(&mut Edit::new(&mut tree, &stamp), &view)?;
|
||||
if tree == head.tree {
|
||||
return Ok(json!({"event_id": null, "accepted": true, "message": "unchanged"}));
|
||||
}
|
||||
if !sidebar_doc::project(&tree).within_limits() {
|
||||
return Err(CliError::Usage(format!(
|
||||
"the sidebar supports at most {} sections and {} channel assignments",
|
||||
sidebar_doc::MAX_SECTIONS,
|
||||
sidebar_doc::MAX_ASSIGNMENTS
|
||||
)));
|
||||
}
|
||||
|
||||
let keys = client.keys();
|
||||
let plaintext = serde_json::to_string(&sidebar_doc::encode(&tree))
|
||||
.map_err(|e| CliError::Other(format!("failed to encode sidebar sections: {e}")))?;
|
||||
let too_large =
|
||||
|| CliError::Usage("sidebar sections would exceed the relay's event size limit".into());
|
||||
// NIP-44 rejects longer plaintexts outright.
|
||||
if plaintext.len() > u16::MAX as usize {
|
||||
return Err(too_large());
|
||||
}
|
||||
let content = nip44::encrypt(
|
||||
keys.secret_key(),
|
||||
&keys.public_key(),
|
||||
plaintext,
|
||||
nip44::Version::V2,
|
||||
)
|
||||
.map_err(|e| CliError::Other(format!("failed to encrypt sidebar sections: {e}")))?;
|
||||
if content.len() > MAX_CIPHERTEXT_BYTES {
|
||||
return Err(too_large());
|
||||
}
|
||||
let created_at = now().as_secs().max(head.created_at + 1);
|
||||
let builder = EventBuilder::new(Kind::Custom(KIND_READ_STATE as u16), content)
|
||||
.tags([Tag::identifier(D_TAG), Tag::hashtag(D_TAG)])
|
||||
.custom_created_at(Timestamp::from(created_at));
|
||||
let raw = client.submit_event(client.sign_event(builder)?).await?;
|
||||
let normalized = parse_write_response(
|
||||
&raw,
|
||||
"a newer sidebar sections update won; run the command again",
|
||||
)?;
|
||||
serde_json::from_str(&normalized)
|
||||
.map_err(|e| CliError::Other(format!("relay response is not JSON: {e}")))
|
||||
}
|
||||
|
||||
async fn run(cmd: SidebarSectionsCmd, client: &BuzzClient) -> Result<Value, CliError> {
|
||||
match cmd {
|
||||
SidebarSectionsCmd::List => {
|
||||
let head = fetch_head(client).await?;
|
||||
serde_json::to_value(sidebar_doc::project(&head.tree))
|
||||
.map_err(|e| CliError::Other(format!("failed to encode sections: {e}")))
|
||||
}
|
||||
SidebarSectionsCmd::Create { name, icon } => {
|
||||
let name = section_name(&name)?;
|
||||
let icon = section_icon(icon.as_deref())?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let mut response = edit(client, |edit, _| {
|
||||
edit.create_section(&id, &name, icon.as_deref());
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
response["section_id"] = json!(id);
|
||||
Ok(response)
|
||||
}
|
||||
SidebarSectionsCmd::Update {
|
||||
section,
|
||||
name,
|
||||
icon,
|
||||
clear_icon,
|
||||
} => {
|
||||
if name.is_none() && icon.is_none() && !clear_icon {
|
||||
return Err(CliError::Usage(
|
||||
"pass --name, --icon, or --clear-icon".into(),
|
||||
));
|
||||
}
|
||||
let name = name.as_deref().map(section_name).transpose()?;
|
||||
let icon = section_icon(icon.as_deref())?;
|
||||
edit(client, |edit, view| {
|
||||
let id = resolve(view, §ion)?.id.clone();
|
||||
if let Some(name) = name {
|
||||
edit.set(&["s", &id, "name"], json!(name));
|
||||
}
|
||||
if clear_icon || icon.is_some() {
|
||||
edit.set(&["s", &id, "icon"], json!(icon));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
SidebarSectionsCmd::Delete { section } => {
|
||||
edit(client, |edit, view| {
|
||||
let id = resolve(view, §ion)?.id.clone();
|
||||
edit.set(&["s", &id, "live"], json!(false));
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
SidebarSectionsCmd::Reorder { sections } => {
|
||||
edit(client, |edit, view| {
|
||||
let ids = sections
|
||||
.iter()
|
||||
.map(|key| Ok(resolve(view, key)?.id.clone()))
|
||||
.collect::<Result<Vec<_>, CliError>>()?;
|
||||
edit.reorder(&ids);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
SidebarSectionsCmd::Move { channels, section } => {
|
||||
let channels = channel_ids(&channels)?;
|
||||
edit(client, |edit, view| {
|
||||
let id = resolve(view, §ion)?.id.clone();
|
||||
for channel in &channels {
|
||||
edit.set(&["a", channel], json!(id));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
SidebarSectionsCmd::Unassign { channels } => {
|
||||
let channels = channel_ids(&channels)?;
|
||||
edit(client, |edit, _| {
|
||||
for channel in &channels {
|
||||
edit.unassign(channel);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn dispatch(cmd: SidebarCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
let SidebarCmd::Sections(cmd) = cmd;
|
||||
let output = run(cmd, client).await?;
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{body::Bytes, extract::State, routing::post, Json, Router};
|
||||
use nostr::Keys;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Relay {
|
||||
events: Vec<Value>,
|
||||
duplicate: bool,
|
||||
}
|
||||
type Shared = Arc<Mutex<Relay>>;
|
||||
|
||||
/// Fake relay: `/query` checks the head filter and returns every stored
|
||||
/// event matching its author and d-tag (more than the relay's `limit`, so
|
||||
/// head selection is exercised); `/events` stores one.
|
||||
async fn fake_relay(relay: Shared) -> String {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/query",
|
||||
post(|State(relay): State<Shared>, body: Bytes| async move {
|
||||
let filters: Vec<Value> = serde_json::from_slice(&body).unwrap();
|
||||
let [filter] = filters.as_slice() else {
|
||||
panic!("one filter expected: {filters:?}")
|
||||
};
|
||||
assert_eq!(filter["kinds"], json!([KIND_READ_STATE]));
|
||||
assert_eq!(filter["#d"], json!([D_TAG]));
|
||||
assert_eq!(filter["limit"], json!(1));
|
||||
let author = &filter["authors"][0];
|
||||
let relay = relay.lock().unwrap();
|
||||
let matching: Vec<_> = relay
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| &e["pubkey"] == author && e["tags"][0] == json!(["d", D_TAG]))
|
||||
.cloned()
|
||||
.collect();
|
||||
Json(json!(matching))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/events",
|
||||
post(|State(relay): State<Shared>, body: Bytes| async move {
|
||||
let event: Value = serde_json::from_slice(&body).unwrap();
|
||||
let mut relay = relay.lock().unwrap();
|
||||
let message = if relay.duplicate {
|
||||
"duplicate: newer head"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
if !relay.duplicate {
|
||||
relay.events.push(event.clone());
|
||||
}
|
||||
Json(json!({"event_id": event["id"], "accepted": true, "message": message}))
|
||||
}),
|
||||
)
|
||||
.with_state(relay);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn setup() -> (BuzzClient, Shared) {
|
||||
let relay = Shared::default();
|
||||
let url = fake_relay(relay.clone()).await;
|
||||
let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap();
|
||||
(client, relay)
|
||||
}
|
||||
|
||||
fn seed_at(client: &BuzzClient, relay: &Shared, plaintext: &str, created_at: u64) -> Value {
|
||||
let keys = client.keys();
|
||||
let content = nip44::encrypt(
|
||||
keys.secret_key(),
|
||||
&keys.public_key(),
|
||||
plaintext,
|
||||
nip44::Version::V2,
|
||||
)
|
||||
.unwrap();
|
||||
let event = EventBuilder::new(Kind::Custom(KIND_READ_STATE as u16), content)
|
||||
.tags([Tag::identifier(D_TAG)])
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.sign_with_keys(keys)
|
||||
.unwrap();
|
||||
let event = serde_json::to_value(event).unwrap();
|
||||
relay.lock().unwrap().events.push(event.clone());
|
||||
event
|
||||
}
|
||||
|
||||
fn seed(client: &BuzzClient, relay: &Shared, plaintext: &str) {
|
||||
seed_at(client, relay, plaintext, now().as_secs());
|
||||
}
|
||||
|
||||
fn doc_with_sections(names: &[&str]) -> String {
|
||||
let mut tree = Tree::new();
|
||||
let stamp = Stamp {
|
||||
now_ms: 1,
|
||||
dev: "0123456789abcdef".into(),
|
||||
};
|
||||
let mut edit = Edit::new(&mut tree, &stamp);
|
||||
for name in names {
|
||||
edit.create_section(&format!("id-{name}"), name, None);
|
||||
}
|
||||
sidebar_doc::encode(&tree).to_string()
|
||||
}
|
||||
|
||||
/// The decrypted document of the newest published event.
|
||||
fn published(client: &BuzzClient, relay: &Shared) -> (nostr::Event, Value) {
|
||||
let raw = relay.lock().unwrap().events.last().cloned().unwrap();
|
||||
let event: nostr::Event = serde_json::from_value(raw).unwrap();
|
||||
let keys = client.keys();
|
||||
let plaintext =
|
||||
nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content).unwrap();
|
||||
(event, serde_json::from_str(&plaintext).unwrap())
|
||||
}
|
||||
|
||||
fn published_count(relay: &Shared) -> usize {
|
||||
relay.lock().unwrap().events.len()
|
||||
}
|
||||
|
||||
const C1: &str = "11111111-1111-4111-8111-111111111111";
|
||||
const C2: &str = "22222222-2222-4222-8222-222222222222";
|
||||
const C3: &str = "abcdef01-2345-4678-89ab-cdef01234567";
|
||||
|
||||
fn mv(channels: &[&str], section: &str) -> SidebarSectionsCmd {
|
||||
SidebarSectionsCmd::Move {
|
||||
channels: channels.iter().map(|c| c.to_string()).collect(),
|
||||
section: section.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sets_up_a_sidebar_in_one_pass_and_syncs_desktop_format() {
|
||||
let (client, relay) = setup().await;
|
||||
let list = run(SidebarSectionsCmd::List, &client).await.unwrap();
|
||||
assert_eq!(list, json!({"sections": [], "assignments": {}}));
|
||||
|
||||
let eng = run(
|
||||
SidebarSectionsCmd::Create {
|
||||
name: " Engineering ".into(),
|
||||
icon: Some("🛠".into()),
|
||||
},
|
||||
&client,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let eng_id = eng["section_id"].as_str().unwrap().to_string();
|
||||
run(
|
||||
SidebarSectionsCmd::Create {
|
||||
name: "Design".into(),
|
||||
icon: None,
|
||||
},
|
||||
&client,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Both channels land in one publish, addressed by section name.
|
||||
let before = published_count(&relay);
|
||||
run(mv(&[C1, C2], "Engineering"), &client).await.unwrap();
|
||||
assert_eq!(published_count(&relay), before + 1);
|
||||
|
||||
let (event, doc) = published(&client, &relay);
|
||||
assert_eq!(event.kind.as_u16() as u32, KIND_READ_STATE);
|
||||
assert!(event.tags.iter().any(|t| t.as_slice() == ["d", D_TAG]));
|
||||
assert!(event.tags.iter().any(|t| t.as_slice() == ["t", D_TAG]));
|
||||
assert_eq!(doc["meta"]["v"], json!(1));
|
||||
assert_eq!(doc["assignments"][C1], json!(eng_id));
|
||||
assert_eq!(doc["sections"][0]["icon"], json!("🛠"));
|
||||
|
||||
// A repeated command publishes nothing.
|
||||
let again = run(mv(&[C1, C2], &eng_id), &client).await.unwrap();
|
||||
assert_eq!(again["message"], json!("unchanged"));
|
||||
assert_eq!(published_count(&relay), before + 1);
|
||||
|
||||
// Invalid input fails before any publish.
|
||||
let err = run(mv(&[C1, "not-a-uuid"], "Design"), &client)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, CliError::Usage(_)), "{err:?}");
|
||||
let err = run(mv(&[C1], "Nope"), &client).await.unwrap_err();
|
||||
assert!(matches!(err, CliError::NotFound(_)), "{err:?}");
|
||||
assert_eq!(published_count(&relay), before + 1);
|
||||
|
||||
run(
|
||||
SidebarSectionsCmd::Reorder {
|
||||
sections: vec!["Design".into()],
|
||||
},
|
||||
&client,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
run(
|
||||
SidebarSectionsCmd::Update {
|
||||
section: eng_id.clone(),
|
||||
name: Some("Eng".into()),
|
||||
icon: None,
|
||||
clear_icon: true,
|
||||
},
|
||||
&client,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
run(
|
||||
SidebarSectionsCmd::Unassign {
|
||||
channels: vec![C2.into()],
|
||||
},
|
||||
&client,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
run(
|
||||
SidebarSectionsCmd::Delete {
|
||||
section: "Design".into(),
|
||||
},
|
||||
&client,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let list = run(SidebarSectionsCmd::List, &client).await.unwrap();
|
||||
assert_eq!(
|
||||
list,
|
||||
json!({
|
||||
"sections": [{"id": eng_id, "name": "Eng", "order": 0}],
|
||||
"assignments": {C1: eng_id},
|
||||
})
|
||||
);
|
||||
let (_, doc) = published(&client, &relay);
|
||||
assert_eq!(doc["sections"], list["sections"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keeps_a_mobile_head_and_never_overwrites_an_unreadable_one() {
|
||||
let (client, relay) = setup().await;
|
||||
seed(
|
||||
&client,
|
||||
&relay,
|
||||
r#"{"version":1,"sections":[{"id":"m1","name":"Mobile","order":0}],"assignments":{}}"#,
|
||||
);
|
||||
run(mv(&[C1], "Mobile"), &client).await.unwrap();
|
||||
let list = run(SidebarSectionsCmd::List, &client).await.unwrap();
|
||||
assert_eq!(list["sections"][0]["name"], json!("Mobile"));
|
||||
assert_eq!(list["assignments"][C1], json!("m1"));
|
||||
|
||||
seed_at(
|
||||
&client,
|
||||
&relay,
|
||||
r#"{"version":1,"meta":{"v":2}}"#,
|
||||
now().as_secs() + 100,
|
||||
);
|
||||
let count = published_count(&relay);
|
||||
assert!(run(SidebarSectionsCmd::List, &client).await.is_err());
|
||||
assert!(run(mv(&[C2], "Mobile"), &client).await.is_err());
|
||||
assert_eq!(published_count(&relay), count);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn picks_the_newest_head_and_publishes_after_it() {
|
||||
let (client, relay) = setup().await;
|
||||
let future = now().as_secs() + 1_000;
|
||||
seed_at(&client, &relay, &doc_with_sections(&["Old"]), future - 1);
|
||||
let a = seed_at(&client, &relay, &doc_with_sections(&["A"]), future);
|
||||
let b = seed_at(&client, &relay, &doc_with_sections(&["B"]), future);
|
||||
// Equal `created_at`: the lower id wins, as on the relay.
|
||||
let winner = if a["id"].as_str() < b["id"].as_str() {
|
||||
"A"
|
||||
} else {
|
||||
"B"
|
||||
};
|
||||
let list = run(SidebarSectionsCmd::List, &client).await.unwrap();
|
||||
assert_eq!(list["sections"][0]["name"], json!(winner));
|
||||
|
||||
// Another author's head is never read as ours.
|
||||
let other = BuzzClient::new(String::new(), Keys::generate(), None, None).unwrap();
|
||||
seed_at(&other, &relay, &doc_with_sections(&["Theirs"]), future + 5);
|
||||
|
||||
// An uppercase channel id is stored in desktop's canonical form.
|
||||
run(mv(&[&C3.to_uppercase()], winner), &client)
|
||||
.await
|
||||
.unwrap();
|
||||
let (event, doc) = published(&client, &relay);
|
||||
assert_eq!(event.created_at.as_secs(), future + 1);
|
||||
assert_eq!(doc["assignments"][C3], json!(format!("id-{winner}")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_a_tampered_head_and_oversized_documents() {
|
||||
let (client, relay) = setup().await;
|
||||
let mut head = seed_at(&client, &relay, &doc_with_sections(&["A"]), 10);
|
||||
head["content"] = json!("tampered");
|
||||
relay.lock().unwrap().events = vec![head];
|
||||
let err = run(mv(&[C1], "A"), &client).await.unwrap_err();
|
||||
assert!(matches!(err, CliError::Other(_)), "{err:?}");
|
||||
assert_eq!(published_count(&relay), 1);
|
||||
|
||||
let names: Vec<String> = (0..sidebar_doc::MAX_SECTIONS)
|
||||
.map(|i| i.to_string())
|
||||
.collect();
|
||||
let names: Vec<&str> = names.iter().map(String::as_str).collect();
|
||||
relay.lock().unwrap().events.clear();
|
||||
seed(&client, &relay, &doc_with_sections(&names));
|
||||
let over_count = SidebarSectionsCmd::Create {
|
||||
name: "one too many".into(),
|
||||
icon: None,
|
||||
};
|
||||
let rename = |len: usize| SidebarSectionsCmd::Update {
|
||||
section: "0".into(),
|
||||
name: Some("x".repeat(len)),
|
||||
icon: None,
|
||||
clear_icon: false,
|
||||
};
|
||||
let err = run(over_count, &client).await.unwrap_err();
|
||||
assert!(matches!(err, CliError::Usage(_)), "{err:?}");
|
||||
|
||||
// Ciphertext over the relay cap, then plaintext over the NIP-44 cap.
|
||||
relay.lock().unwrap().events.clear();
|
||||
seed(&client, &relay, &doc_with_sections(&["0"]));
|
||||
for cmd in [rename(25_000), rename(70_000)] {
|
||||
let err = run(cmd, &client).await.unwrap_err();
|
||||
assert!(matches!(err, CliError::Usage(_)), "{err:?}");
|
||||
}
|
||||
assert_eq!(published_count(&relay), 1);
|
||||
|
||||
let err = run(
|
||||
SidebarSectionsCmd::Update {
|
||||
section: "0".into(),
|
||||
name: None,
|
||||
icon: Some(" ".into()),
|
||||
clear_icon: false,
|
||||
},
|
||||
&client,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, CliError::Usage(_)), "{err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_dominated_write_is_a_conflict() {
|
||||
let (client, relay) = setup().await;
|
||||
relay.lock().unwrap().duplicate = true;
|
||||
let err = run(
|
||||
SidebarSectionsCmd::Create {
|
||||
name: "Late".into(),
|
||||
icon: None,
|
||||
},
|
||||
&client,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, CliError::Conflict(_)), "{err:?}");
|
||||
}
|
||||
}
|
||||
556
crates/buzz-cli/src/commands/sidebar_doc.rs
Normal file
556
crates/buzz-cli/src/commands/sidebar_doc.rs
Normal file
@@ -0,0 +1,556 @@
|
||||
//! The synced channel-sections document (kind:30078, d=`channel-sections`).
|
||||
//!
|
||||
//! Wire-compatible with desktop `channelSectionsSync.ts`, `sidebarLaneStore.ts`
|
||||
//! and `sidebarLwwMap.ts`: a tree of per-field last-writer-wins registers
|
||||
//! `[v, dev, value]` under `meta`, plus the projected `sections`/`assignments`
|
||||
//! fields that meta-less clients (mobile) read and write.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
/// Register tree: `{"s": {section_id: {name, icon, order, live}}, "a": {channel_id: section_id|null}}`.
|
||||
pub(crate) type Tree = Map<String, Value>;
|
||||
|
||||
/// Live-section cap shared with desktop; larger documents are never published.
|
||||
pub(crate) const MAX_SECTIONS: usize = 100;
|
||||
/// Live-assignment cap shared with desktop.
|
||||
pub(crate) const MAX_ASSIGNMENTS: usize = 1_000;
|
||||
|
||||
/// Device id desktop stamps on registers imported from meta-less payloads.
|
||||
const LEGACY_DEV: &str = "0000000000000000";
|
||||
const MAX_SAFE_INTEGER: i64 = (1 << 53) - 1;
|
||||
|
||||
/// One live section, densely renumbered by `(order, id)`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub(crate) struct Section {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
pub order: usize,
|
||||
}
|
||||
|
||||
/// The view every client renders: live sections and assignments to them.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub(crate) struct Projection {
|
||||
pub sections: Vec<Section>,
|
||||
pub assignments: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Projection {
|
||||
pub(crate) fn within_limits(&self) -> bool {
|
||||
self.sections.len() <= MAX_SECTIONS && self.assignments.len() <= MAX_ASSIGNMENTS
|
||||
}
|
||||
}
|
||||
|
||||
/// `Number.isSafeInteger`, accepting integral floats such as `1.0`.
|
||||
fn safe_int(value: &Value) -> Option<i64> {
|
||||
let n = match value.as_i64() {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
let f = value.as_f64()?;
|
||||
if f.fract() != 0.0 || f.abs() > MAX_SAFE_INTEGER as f64 {
|
||||
return None;
|
||||
}
|
||||
f as i64
|
||||
}
|
||||
};
|
||||
(-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER)
|
||||
.contains(&n)
|
||||
.then_some(n)
|
||||
}
|
||||
|
||||
fn is_dev(value: &Value) -> bool {
|
||||
value
|
||||
.as_str()
|
||||
.is_some_and(|s| s.len() == 16 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')))
|
||||
}
|
||||
|
||||
fn valid_reg(node: &Value, leaf: Leaf) -> Option<Value> {
|
||||
let reg = node.as_array().filter(|reg| reg.len() == 3)?;
|
||||
let v = safe_int(®[0]).filter(|v| *v >= 0)?;
|
||||
(is_dev(®[1]) && leaf(®[2])).then(|| json!([v, reg[1], reg[2]]))
|
||||
}
|
||||
|
||||
fn is_string(value: &Value) -> bool {
|
||||
value.is_string()
|
||||
}
|
||||
fn is_string_or_null(value: &Value) -> bool {
|
||||
value.is_string() || value.is_null()
|
||||
}
|
||||
fn is_order(value: &Value) -> bool {
|
||||
safe_int(value).is_some()
|
||||
}
|
||||
fn is_bool(value: &Value) -> bool {
|
||||
value.is_boolean()
|
||||
}
|
||||
|
||||
type Leaf = fn(&Value) -> bool;
|
||||
|
||||
const SECTION_FIELDS: [(&str, Leaf); 4] = [
|
||||
("name", is_string),
|
||||
("icon", is_string_or_null),
|
||||
("order", is_order),
|
||||
("live", is_bool),
|
||||
];
|
||||
|
||||
/// Own entries of a JSON object, skipping the `__proto__` key desktop drops.
|
||||
fn entries(node: &Value) -> impl Iterator<Item = (&String, &Value)> {
|
||||
node.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(key, _)| key.as_str() != "__proto__")
|
||||
}
|
||||
|
||||
/// Keeps only well-formed registers; an invalid one is dropped while its
|
||||
/// valid siblings survive (desktop `validTree` with the sections shape).
|
||||
fn valid_tree(fields: &Value) -> Option<Tree> {
|
||||
fields.as_object()?;
|
||||
let mut tree = Tree::new();
|
||||
if let Some(s) = fields.get("s").filter(|s| s.is_object()) {
|
||||
let mut sections = Map::new();
|
||||
for (id, node) in entries(s) {
|
||||
if !node.is_object() {
|
||||
continue;
|
||||
}
|
||||
let mut out = Map::new();
|
||||
for (field, leaf) in SECTION_FIELDS {
|
||||
if let Some(reg) = node.get(field).and_then(|n| valid_reg(n, leaf)) {
|
||||
out.insert(field.to_string(), reg);
|
||||
}
|
||||
}
|
||||
sections.insert(id.clone(), Value::Object(out));
|
||||
}
|
||||
tree.insert("s".into(), Value::Object(sections));
|
||||
}
|
||||
if let Some(a) = fields.get("a").filter(|a| a.is_object()) {
|
||||
let assignments = entries(a)
|
||||
.filter_map(|(id, node)| Some((id.clone(), valid_reg(node, is_string_or_null)?)))
|
||||
.collect();
|
||||
tree.insert("a".into(), Value::Object(assignments));
|
||||
}
|
||||
Some(tree)
|
||||
}
|
||||
|
||||
/// JS `Math.round`, as an integer when it is a safe one.
|
||||
fn js_round(f: f64) -> Value {
|
||||
let rounded = (f + 0.5).floor();
|
||||
if rounded.abs() <= MAX_SAFE_INTEGER as f64 {
|
||||
json!(rounded as i64)
|
||||
} else {
|
||||
json!(rounded)
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers for a meta-less (legacy/mobile) payload, every value stamped at `legacy_v`.
|
||||
fn from_legacy(json: &Value, legacy_v: i64) -> Value {
|
||||
let stamp = |value: Value| json!([legacy_v, LEGACY_DEV, value]);
|
||||
let mut s = Map::new();
|
||||
for entry in json
|
||||
.get("sections")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let (Some(id), Some(name), Some(order)) = (
|
||||
entry.get("id").and_then(Value::as_str),
|
||||
entry.get("name").and_then(Value::as_str),
|
||||
entry.get("order").and_then(Value::as_f64),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let icon = entry
|
||||
.get("icon")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|icon| !icon.is_empty());
|
||||
s.insert(
|
||||
id.to_string(),
|
||||
json!({
|
||||
"name": stamp(json!(name)),
|
||||
"icon": stamp(json!(icon)),
|
||||
"order": stamp(js_round(order)),
|
||||
"live": stamp(json!(true)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
let a: Map<String, Value> = entries(json.get("assignments").unwrap_or(&Value::Null))
|
||||
.filter(|(_, target)| target.as_str().is_some_and(|t| s.contains_key(t)))
|
||||
.map(|(id, target)| (id.clone(), stamp(target.clone())))
|
||||
.collect();
|
||||
json!({ "s": s, "a": a })
|
||||
}
|
||||
|
||||
/// Decodes a `version: 1` payload, importing a meta-less one at `legacy_v`.
|
||||
/// Returns `None` when unreadable, including an unknown `meta.v`.
|
||||
pub(crate) fn decode(json: &Value, legacy_v: i64) -> Option<Tree> {
|
||||
let is_one = |value: Option<&Value>| value.and_then(safe_int) == Some(1);
|
||||
if !is_one(json.get("version")) {
|
||||
return None;
|
||||
}
|
||||
match json.get("meta") {
|
||||
None => valid_tree(&from_legacy(json, legacy_v)),
|
||||
Some(meta) if is_one(meta.get("v")) => {
|
||||
let mut fields = meta.as_object()?.clone();
|
||||
fields.remove("v");
|
||||
valid_tree(&Value::Object(fields))
|
||||
}
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn val<'a>(node: &'a Value, field: &str) -> Option<&'a Value> {
|
||||
node.get(field)?.as_array()?.get(2)
|
||||
}
|
||||
|
||||
/// Live sections by `(order, id)` with their stored `order`.
|
||||
fn ranked(tree: &Tree) -> Vec<(String, String, Option<String>, i64)> {
|
||||
let mut out: Vec<_> = tree
|
||||
.get("s")
|
||||
.map(entries)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(_, node)| val(node, "live") == Some(&json!(true)))
|
||||
.filter_map(|(id, node)| {
|
||||
let name = val(node, "name")?.as_str()?.to_string();
|
||||
let icon = val(node, "icon")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|icon| !icon.is_empty())
|
||||
.map(str::to_string);
|
||||
let order = val(node, "order").and_then(safe_int).unwrap_or(0);
|
||||
Some((id.clone(), name, icon, order))
|
||||
})
|
||||
.collect();
|
||||
// JS compares strings by UTF-16 code unit.
|
||||
out.sort_by(|a, b| {
|
||||
a.3.cmp(&b.3)
|
||||
.then_with(|| a.0.encode_utf16().cmp(b.0.encode_utf16()))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Highest stored `order` among live sections, or -1 when there are none.
|
||||
fn max_live_order(tree: &Tree) -> i64 {
|
||||
ranked(tree).iter().map(|s| s.3).max().unwrap_or(-1)
|
||||
}
|
||||
|
||||
pub(crate) fn project(tree: &Tree) -> Projection {
|
||||
let sections: Vec<Section> = ranked(tree)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(order, (id, name, icon, _))| Section {
|
||||
id,
|
||||
name,
|
||||
icon,
|
||||
order,
|
||||
})
|
||||
.collect();
|
||||
let assignments = tree
|
||||
.get("a")
|
||||
.map(entries)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|(channel, reg)| {
|
||||
let target = reg.as_array()?.get(2)?.as_str()?;
|
||||
sections
|
||||
.iter()
|
||||
.any(|s| s.id == target)
|
||||
.then(|| (channel.clone(), target.to_string()))
|
||||
})
|
||||
.collect();
|
||||
Projection {
|
||||
sections,
|
||||
assignments,
|
||||
}
|
||||
}
|
||||
|
||||
/// The plaintext document: projected legacy fields plus the register tree.
|
||||
pub(crate) fn encode(tree: &Tree) -> Value {
|
||||
let Projection {
|
||||
sections,
|
||||
assignments,
|
||||
} = project(tree);
|
||||
let mut meta = tree.clone();
|
||||
meta.insert("v".into(), json!(1));
|
||||
json!({
|
||||
"version": 1,
|
||||
"sections": sections,
|
||||
"assignments": assignments,
|
||||
"meta": meta,
|
||||
})
|
||||
}
|
||||
|
||||
fn max_version(node: &Value) -> i64 {
|
||||
match node {
|
||||
Value::Array(reg) => reg.first().and_then(safe_int).unwrap_or(0),
|
||||
Value::Object(map) => map.values().map(max_version).max().unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The version and device every register written by one edit shares.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Stamp {
|
||||
pub now_ms: i64,
|
||||
pub dev: String,
|
||||
}
|
||||
|
||||
/// One edit against a tree. Each call to [`Edit::set`] that changes a leaf
|
||||
/// writes a register stamped `max(now, newest + 1)`, like desktop `setRegs`.
|
||||
pub(crate) struct Edit<'a> {
|
||||
tree: &'a mut Tree,
|
||||
reg: (i64, String),
|
||||
}
|
||||
|
||||
impl<'a> Edit<'a> {
|
||||
pub(crate) fn new(tree: &'a mut Tree, stamp: &Stamp) -> Self {
|
||||
let newest = tree.values().map(max_version).max().unwrap_or(0);
|
||||
let v = stamp.now_ms.max(newest + 1);
|
||||
Self {
|
||||
tree,
|
||||
reg: (v, stamp.dev.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn current(&self, path: &[&str]) -> Option<&Value> {
|
||||
let (first, rest) = path.split_first()?;
|
||||
let mut node = self.tree.get(*first)?;
|
||||
for key in rest {
|
||||
node = node.get(key)?;
|
||||
}
|
||||
node.as_array()?.get(2)
|
||||
}
|
||||
|
||||
fn write(&mut self, parents: &[&str], leaf: &str, value: Value) {
|
||||
let reg = json!([self.reg.0, self.reg.1, value]);
|
||||
let mut node = &mut *self.tree;
|
||||
for key in parents {
|
||||
let child = node.entry(key.to_string()).or_insert_with(|| json!({}));
|
||||
if !child.is_object() {
|
||||
*child = json!({});
|
||||
}
|
||||
let Value::Object(map) = child else {
|
||||
return;
|
||||
};
|
||||
node = map;
|
||||
}
|
||||
node.insert(leaf.to_string(), reg);
|
||||
}
|
||||
|
||||
/// Writes `value` at `path` unless it already holds that value.
|
||||
pub(crate) fn set(&mut self, path: &[&str], value: Value) {
|
||||
let Some((leaf, parents)) = path.split_last() else {
|
||||
return;
|
||||
};
|
||||
if self.current(path) != Some(&value) {
|
||||
self.write(parents, leaf, value);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn create_section(&mut self, id: &str, name: &str, icon: Option<&str>) {
|
||||
let order = max_live_order(self.tree) + 1;
|
||||
self.set(&["s", id, "name"], json!(name));
|
||||
self.set(&["s", id, "icon"], json!(icon));
|
||||
self.set(&["s", id, "order"], json!(order));
|
||||
self.set(&["s", id, "live"], json!(true));
|
||||
}
|
||||
|
||||
/// Orders live sections `requested` first, then the rest in their current
|
||||
/// relative order. Any change rewrites every live `order` register.
|
||||
pub(crate) fn reorder(&mut self, requested: &[String]) {
|
||||
let live: Vec<String> = project(self.tree)
|
||||
.sections
|
||||
.into_iter()
|
||||
.map(|s| s.id)
|
||||
.collect();
|
||||
let mut ids: Vec<String> = Vec::with_capacity(live.len());
|
||||
for id in requested.iter().chain(&live) {
|
||||
if live.contains(id) && !ids.contains(id) {
|
||||
ids.push(id.clone());
|
||||
}
|
||||
}
|
||||
let unchanged = ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(index, id)| self.current(&["s", id, "order"]) == Some(&json!(index)));
|
||||
if unchanged {
|
||||
return;
|
||||
}
|
||||
for (index, id) in ids.iter().enumerate() {
|
||||
self.write(&["s", id], "order", json!(index));
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears an assignment that exists (as a value or tombstone).
|
||||
pub(crate) fn unassign(&mut self, channel_id: &str) {
|
||||
let exists = self.tree.get("a").and_then(|a| a.get(channel_id)).is_some();
|
||||
if exists {
|
||||
self.set(&["a", channel_id], Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const DEV: &str = "0123456789abcdef";
|
||||
|
||||
fn stamp(now_ms: i64) -> Stamp {
|
||||
Stamp {
|
||||
now_ms,
|
||||
dev: DEV.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn reg(v: i64, value: Value) -> Value {
|
||||
json!([v, DEV, value])
|
||||
}
|
||||
|
||||
/// A document in the shape desktop `encodeDoc` publishes.
|
||||
fn desktop_doc() -> Value {
|
||||
json!({
|
||||
"version": 1,
|
||||
"sections": [],
|
||||
"assignments": {},
|
||||
"meta": {
|
||||
"v": 1,
|
||||
"s": {
|
||||
"b": {"name": reg(5, json!("Beta")), "icon": reg(5, Value::Null),
|
||||
"order": reg(5, json!(1)), "live": reg(5, json!(true))},
|
||||
"a": {"name": reg(4, json!("Alpha")), "icon": reg(4, json!("🚀")),
|
||||
"order": reg(4, json!(0)), "live": reg(4, json!(true))},
|
||||
"dead": {"name": reg(3, json!("Gone")), "icon": reg(3, Value::Null),
|
||||
"order": reg(3, json!(2)), "live": reg(6, json!(false))},
|
||||
"bad": {"name": [7, "not-a-dev", "Bad"], "order": reg(7, json!(3)),
|
||||
"live": reg(7, json!(true))},
|
||||
},
|
||||
"a": {
|
||||
"c1": reg(8, json!("a")),
|
||||
"c2": reg(8, json!("dead")),
|
||||
"c3": reg(9, Value::Null),
|
||||
},
|
||||
"future": reg(1, json!("ignored")),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_desktop_document_and_projects_live_state() {
|
||||
let tree = decode(&desktop_doc(), 0).expect("readable");
|
||||
let view = project(&tree);
|
||||
let names: Vec<_> = view
|
||||
.sections
|
||||
.iter()
|
||||
.map(|s| (s.name.as_str(), s.order))
|
||||
.collect();
|
||||
// `bad` keeps its valid registers but has no name, so it is not live.
|
||||
assert_eq!(names, [("Alpha", 0), ("Beta", 1)]);
|
||||
assert_eq!(view.sections[0].icon.as_deref(), Some("🚀"));
|
||||
assert_eq!(
|
||||
view.assignments,
|
||||
BTreeMap::from([("c1".to_string(), "a".to_string())])
|
||||
);
|
||||
assert!(!tree.contains_key("future"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_payloads_are_rejected() {
|
||||
for payload in [
|
||||
json!({"version": 2, "meta": {"v": 1}}),
|
||||
json!({"version": 1, "meta": {"v": 2}}),
|
||||
json!({"meta": {"v": 1}}),
|
||||
json!("text"),
|
||||
] {
|
||||
assert!(decode(&payload, 0).is_none(), "{payload}");
|
||||
}
|
||||
let bad_version = json!({"version": 1.0, "meta": {"v": 1, "s": {"x": {
|
||||
"order": [i64::MIN, DEV, 0], "live": [-1, DEV, true], "name": [1, DEV, "X"]}}}});
|
||||
let tree = decode(&bad_version, 0).expect("readable");
|
||||
assert_eq!(tree["s"]["x"], json!({"name": [1, DEV, "X"]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_mobile_flat_payload_at_the_event_version() {
|
||||
let mobile = json!({
|
||||
"version": 1,
|
||||
"sections": [
|
||||
{"id": "x", "name": "Team", "icon": " ", "order": 1.6},
|
||||
{"id": "y", "name": "Ops", "order": 0},
|
||||
{"name": "no id", "order": 0},
|
||||
],
|
||||
"assignments": {"c1": "x", "c2": "missing"},
|
||||
});
|
||||
let tree = decode(&mobile, 42_000).expect("readable");
|
||||
assert_eq!(tree["s"]["x"]["order"], json!([42_000, LEGACY_DEV, 2]));
|
||||
assert_eq!(tree["s"]["x"]["icon"], json!([42_000, LEGACY_DEV, null]));
|
||||
let view = project(&tree);
|
||||
let names: Vec<_> = view.sections.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, ["Ops", "Team"]);
|
||||
assert_eq!(view.assignments.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_round_trips_and_carries_the_projection() {
|
||||
let tree = decode(&desktop_doc(), 0).expect("readable");
|
||||
let doc = encode(&tree);
|
||||
assert_eq!(doc["meta"]["v"], json!(1));
|
||||
assert_eq!(doc["sections"][0]["name"], json!("Alpha"));
|
||||
assert!(doc["sections"][1].get("icon").is_none());
|
||||
assert_eq!(doc["assignments"], json!({"c1": "a"}));
|
||||
assert_eq!(decode(&doc, 0).expect("readable"), tree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_share_one_stamp_newer_than_every_register() {
|
||||
let mut tree = decode(&desktop_doc(), 0).expect("readable");
|
||||
let mut edit = Edit::new(&mut tree, &stamp(1));
|
||||
edit.create_section("n", "New", None);
|
||||
edit.set(&["a", "c9"], json!("n"));
|
||||
assert_eq!(tree["s"]["n"]["order"], reg(10, json!(2)));
|
||||
assert_eq!(tree["a"]["c9"], reg(10, json!("n")));
|
||||
|
||||
let mut edit = Edit::new(&mut tree, &stamp(500));
|
||||
edit.set(&["s", "n", "name"], json!("New"));
|
||||
assert_eq!(
|
||||
tree["s"]["n"]["name"],
|
||||
reg(10, json!("New")),
|
||||
"unchanged leaf"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_puts_requested_first_and_keeps_the_rest() {
|
||||
let mut tree = Tree::new();
|
||||
let mut edit = Edit::new(&mut tree, &stamp(1));
|
||||
for id in ["a", "b", "c"] {
|
||||
edit.create_section(id, id, None);
|
||||
}
|
||||
let mut edit = Edit::new(&mut tree, &stamp(2));
|
||||
edit.reorder(&["c".into(), "dead".into(), "c".into()]);
|
||||
let ids: Vec<_> = project(&tree).sections.into_iter().map(|s| s.id).collect();
|
||||
assert_eq!(ids, ["c", "a", "b"]);
|
||||
assert_eq!(tree["s"]["a"]["order"], json!([2, DEV, 1]));
|
||||
|
||||
let before = tree.clone();
|
||||
Edit::new(&mut tree, &stamp(3)).reorder(&["c".into(), "a".into()]);
|
||||
assert_eq!(
|
||||
tree, before,
|
||||
"a reorder to the current order writes nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unassign_tombstones_only_existing_assignments() {
|
||||
let mut tree = Tree::new();
|
||||
let mut edit = Edit::new(&mut tree, &stamp(1));
|
||||
edit.create_section("s1", "S", None);
|
||||
edit.set(&["a", "c1"], json!("s1"));
|
||||
let mut edit = Edit::new(&mut tree, &stamp(2));
|
||||
edit.unassign("c1");
|
||||
edit.unassign("never");
|
||||
assert_eq!(tree["a"]["c1"], json!([2, DEV, null]));
|
||||
assert!(tree["a"].get("never").is_none());
|
||||
}
|
||||
}
|
||||
@@ -284,6 +284,9 @@ enum Cmd {
|
||||
/// Get and set channel canvas documents
|
||||
#[command(subcommand)]
|
||||
Canvas(CanvasCmd),
|
||||
/// Organize your own sidebar into channel sections
|
||||
#[command(subcommand)]
|
||||
Sidebar(SidebarCmd),
|
||||
/// Add, remove, and list emoji reactions
|
||||
#[command(subcommand)]
|
||||
Reactions(ReactionsCmd),
|
||||
@@ -1022,6 +1025,71 @@ pub enum ReactionsCmd {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum SidebarCmd {
|
||||
/// List, create, and arrange your channel sections (synced with Buzz desktop)
|
||||
#[command(subcommand)]
|
||||
Sections(SidebarSectionsCmd),
|
||||
}
|
||||
|
||||
/// `--section` takes a section id, or a section name that is unique.
|
||||
#[derive(Subcommand)]
|
||||
pub enum SidebarSectionsCmd {
|
||||
/// List your sections and the channels assigned to each one
|
||||
List,
|
||||
/// Create a section at the end of your sidebar
|
||||
Create {
|
||||
/// Section name
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
/// Emoji shown next to the section name
|
||||
#[arg(long)]
|
||||
icon: Option<String>,
|
||||
},
|
||||
/// Rename a section or change its icon
|
||||
Update {
|
||||
/// Section id or unique name
|
||||
#[arg(long)]
|
||||
section: String,
|
||||
/// New section name
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// New emoji icon
|
||||
#[arg(long, conflicts_with = "clear_icon")]
|
||||
icon: Option<String>,
|
||||
/// Remove the section icon
|
||||
#[arg(long, default_value_t = false)]
|
||||
clear_icon: bool,
|
||||
},
|
||||
/// Delete a section; its channels return to the default channel list
|
||||
Delete {
|
||||
/// Section id or unique name
|
||||
#[arg(long)]
|
||||
section: String,
|
||||
},
|
||||
/// Put sections in order; unlisted sections keep their order after them
|
||||
Reorder {
|
||||
/// Section id or unique name, in the new order (repeatable)
|
||||
#[arg(long = "section", required = true)]
|
||||
sections: Vec<String>,
|
||||
},
|
||||
/// Move one or more channels into a section
|
||||
Move {
|
||||
/// Channel UUID (repeatable)
|
||||
#[arg(long = "channel", required = true)]
|
||||
channels: Vec<String>,
|
||||
/// Section id or unique name
|
||||
#[arg(long)]
|
||||
section: String,
|
||||
},
|
||||
/// Return one or more channels to the default channel list
|
||||
Unassign {
|
||||
/// Channel UUID (repeatable)
|
||||
#[arg(long = "channel", required = true)]
|
||||
channels: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum EmojiCmd {
|
||||
/// List the workspace custom emoji palette (union of every member's set)
|
||||
@@ -2430,6 +2498,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {
|
||||
Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await,
|
||||
Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await,
|
||||
Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await,
|
||||
Cmd::Sidebar(sub) => commands::sidebar::dispatch(sub, &client).await,
|
||||
Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await,
|
||||
Cmd::Emoji(sub) => commands::emoji::dispatch(sub, &client).await,
|
||||
Cmd::Gifs(sub) => commands::gifs::dispatch(sub, &client).await,
|
||||
@@ -2662,6 +2731,7 @@ mod tests {
|
||||
"projects",
|
||||
"reactions",
|
||||
"repos",
|
||||
"sidebar",
|
||||
"social",
|
||||
"upload",
|
||||
"users",
|
||||
|
||||
@@ -67,6 +67,7 @@ Output varies by command group — `--help` shows flags but not response shapes.
|
||||
| `mem hash` | SHA-256 hex string |
|
||||
| `mem set/patch/rm` | nothing to stdout; progress to stderr |
|
||||
| `mem ls` | tab-delimited (`slug\tcreated_at\tevent_id`) by default; `--json` for JSON array |
|
||||
| `sidebar sections list` | `{sections: [{id, name, icon?, order}], assignments: {channel_id: section_id}}` |
|
||||
| `reactions get` | `{"reactions": [{emoji, count, pubkeys}]}` — aggregated, not raw events |
|
||||
| `pack validate/inspect` | human-readable text, not JSON |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user