cob: Add support for media embeds in COBs

This change allows for files (blobs) to be embedded into COB entries.
This allows for things like image attachments in issue comments for example.

For now, we only enable this in the `issue` COB.

The way it works is that relevant COB actions carry metadata about which
files are attached to them; and we store those files as blobs inside the
COB entry's *tree* object, under an `embeds/` folder.

The `Embed<T>` type is used for the above, and either carries actual
content, or carries a content-id. Retrieving the actual content is as
simple as asking the repository for a blob with that content-id.

As a possible future extension, MIME types could be stored alongside the
files in a "metadata" file. This could help clients display the content
appropriately.
This commit is contained in:
cloudhead 2023-08-17 12:23:20 +02:00
parent f282e09dce
commit 534503d002
No known key found for this signature in database
31 changed files with 545 additions and 101 deletions

1
Cargo.lock generated
View File

@ -1924,6 +1924,7 @@ dependencies = [
"git2", "git2",
"log", "log",
"nonempty 0.8.1", "nonempty 0.8.1",
"once_cell",
"qcheck", "qcheck",
"qcheck-macros", "qcheck-macros",
"radicle-crypto", "radicle-crypto",

View File

@ -110,7 +110,7 @@ fn comment(
let (comment_id, _) = issue.comments().next().expect("root comment always exists"); let (comment_id, _) = issue.comments().next().expect("root comment always exists");
*comment_id *comment_id
}); });
let comment_id = issue.comment(message, comment_id, &signer)?; let comment_id = issue.comment(message, comment_id, vec![], &signer)?;
term::print(comment_id); term::print(comment_id);
return Ok(()); return Ok(());

View File

@ -295,7 +295,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
description: Some(description), description: Some(description),
labels, labels,
} => { } => {
let issue = issues.create(title, description, labels.as_slice(), &[], &signer)?; let issue = issues.create(title, description, labels.as_slice(), &[], [], &signer)?;
if !options.quiet { if !options.quiet {
show_issue(&issue, issue.id())?; show_issue(&issue, issue.id())?;
} }
@ -569,6 +569,7 @@ fn open<R: WriteRepository + cob::Store, G: Signer>(
description.trim(), description.trim(),
meta.labels.as_slice(), meta.labels.as_slice(),
meta.assignees.as_slice(), meta.assignees.as_slice(),
[],
signer, signer,
)?; )?;
if !options.quiet { if !options.quiet {
@ -598,7 +599,7 @@ fn edit<R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(
tx.edit(t)?; tx.edit(t)?;
} }
if let Some(d) = description { if let Some(d) = description {
tx.edit_comment(desc_id, d)?; tx.edit_comment(desc_id, d, vec![])?;
} }
Ok(()) Ok(())
@ -621,7 +622,7 @@ fn edit<R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(
issue.transaction("Edit", signer, |tx| { issue.transaction("Edit", signer, |tx| {
tx.edit(edited.title)?; tx.edit(edited.title)?;
tx.edit_comment(desc_id, description)?; tx.edit_comment(desc_id, description, vec![])?;
tx.label(edited.labels)?; tx.label(edited.labels)?;
tx.assign(edited.assignees)?; tx.assign(edited.assignees)?;

View File

@ -794,6 +794,7 @@ fn test_cob_replication() {
"I don't know what it is", "I don't know what it is",
&[], &[],
&[], &[],
[],
&bob.signer, &bob.signer,
) )
.unwrap(); .unwrap();
@ -843,6 +844,7 @@ fn test_cob_deletion() {
"I don't know what it is", "I don't know what it is",
&[], &[],
&[], &[],
[],
&alice.signer, &alice.signer,
) )
.unwrap(); .unwrap();

View File

@ -16,6 +16,7 @@ keywords = ["radicle", "cob", "cobs"]
fastrand = { version = "1.9.0" } fastrand = { version = "1.9.0" }
log = { version = "0.4.17" } log = { version = "0.4.17" }
nonempty = { version = "0.8.1", features = ["serialize"] } nonempty = { version = "0.8.1", features = ["serialize"] }
once_cell = { version = "1.13" }
radicle-git-ext = { version = "0.6.0", features = ["serde"] } radicle-git-ext = { version = "0.6.0", features = ["serde"] }
serde_json = { version = "1.0" } serde_json = { version = "1.0" }
thiserror = { version = "1.0" } thiserror = { version = "1.0" }

View File

@ -2,11 +2,13 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::convert::TryFrom; use std::convert::TryFrom;
use std::path::PathBuf;
use git_ext::author::Author; use git_ext::author::Author;
use git_ext::commit::{headers::Headers, Commit}; use git_ext::commit::{headers::Headers, Commit};
use git_ext::Oid; use git_ext::Oid;
use nonempty::NonEmpty; use nonempty::NonEmpty;
use once_cell::sync::Lazy;
use radicle_git_ext::commit::trailers::OwnedTrailer; use radicle_git_ext::commit::trailers::OwnedTrailer;
use crate::change::store::Version; use crate::change::store::Version;
@ -16,10 +18,13 @@ use crate::{
change::{self, store, Change}, change::{self, store, Change},
history::entry, history::entry,
signatures::{ExtendedSignature, Signatures}, signatures::{ExtendedSignature, Signatures},
trailers, trailers, Embed,
}; };
const MANIFEST_BLOB_NAME: &str = "manifest"; /// Name of the COB manifest file.
pub const MANIFEST_BLOB_NAME: &str = "manifest";
/// Path under which COB embeds are kept.
pub static EMBEDS_PATH: Lazy<PathBuf> = Lazy::new(|| PathBuf::from("embeds"));
pub mod error { pub mod error {
use std::str::Utf8Error; use std::str::Utf8Error;
@ -102,10 +107,11 @@ impl change::Storage for git2::Repository {
type_name, type_name,
tips, tips,
message, message,
embeds,
contents, contents,
} = spec; } = spec;
let manifest = store::Manifest::new(type_name, Version::default()); let manifest = store::Manifest::new(type_name, Version::default());
let revision = write_manifest(self, &manifest, &contents)?; let revision = write_manifest(self, &manifest, embeds, &contents)?;
let tree = self.find_tree(revision)?; let tree = self.find_tree(revision)?;
let signature = { let signature = {
let sig = signer.sign(revision.as_bytes()); let sig = signer.sign(revision.as_bytes());
@ -281,6 +287,8 @@ where
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
let (author, timestamp) = if let Ok(s) = std::env::var(crate::git::RAD_COMMIT_TIME) { let (author, timestamp) = if let Ok(s) = std::env::var(crate::git::RAD_COMMIT_TIME) {
// SAFETY: It's ok to panic here, since this is only enabled in debug mode.
#[allow(clippy::unwrap_used)]
let timestamp = s.trim().parse::<i64>().unwrap(); let timestamp = s.trim().parse::<i64>().unwrap();
let author = Author { let author = Author {
time: git_ext::author::Time::new(timestamp, 0), time: git_ext::author::Time::new(timestamp, 0),
@ -308,24 +316,47 @@ where
fn write_manifest( fn write_manifest(
repo: &git2::Repository, repo: &git2::Repository,
manifest: &store::Manifest, manifest: &store::Manifest,
embeds: Vec<Embed>,
contents: &NonEmpty<Vec<u8>>, contents: &NonEmpty<Vec<u8>>,
) -> Result<git2::Oid, git2::Error> { ) -> Result<git2::Oid, git2::Error> {
let mut tb = repo.treebuilder(None)?; let mut root = repo.treebuilder(None)?;
// Insert manifest file into tree.
{
// SAFETY: we're serializing to an in memory buffer so the only source of // SAFETY: we're serializing to an in memory buffer so the only source of
// errors here is a programming error, which we can't recover from // errors here is a programming error, which we can't recover from.
let serialized_manifest = serde_json::to_vec(manifest).unwrap(); #[allow(clippy::unwrap_used)]
let manifest_oid = repo.blob(&serialized_manifest)?; let manifest = serde_json::to_vec(manifest).unwrap();
tb.insert( let manifest_oid = repo.blob(&manifest)?;
root.insert(
MANIFEST_BLOB_NAME, MANIFEST_BLOB_NAME,
manifest_oid, manifest_oid,
git2::FileMode::Blob.into(), git2::FileMode::Blob.into(),
)?; )?;
}
// Insert each COB entry.
for (ix, op) in contents.iter().enumerate() { for (ix, op) in contents.iter().enumerate() {
let oid = repo.blob(op.as_ref())?; let oid = repo.blob(op.as_ref())?;
tb.insert(&ix.to_string(), oid, git2::FileMode::Blob.into())?; root.insert(&ix.to_string(), oid, git2::FileMode::Blob.into())?;
} }
let tree_oid = tb.write()?;
Ok(tree_oid) // Insert each embed in a tree at `/embeds`.
if !embeds.is_empty() {
let mut embeds_tree = repo.treebuilder(None)?;
for embed in embeds {
let oid = repo.blob(&embed.content)?;
let path = PathBuf::from(embed.name);
embeds_tree.insert(path, oid, git2::FileMode::Blob.into())?;
}
let oid = embeds_tree.write()?;
root.insert(&*EMBEDS_PATH, oid, git2::FileMode::Tree.into())?;
}
let oid = root.write()?;
Ok(oid)
} }

View File

@ -48,6 +48,7 @@ pub struct Template<Id> {
pub type_name: TypeName, pub type_name: TypeName,
pub tips: Vec<Id>, pub tips: Vec<Id>,
pub message: String, pub message: String,
pub embeds: Vec<Embed>,
pub contents: NonEmpty<Vec<u8>>, pub contents: NonEmpty<Vec<u8>>,
} }
@ -179,3 +180,38 @@ impl Version {
NonZeroUsize::new(version).map(Self) NonZeroUsize::new(version).map(Self)
} }
} }
/// Embedded object.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Embed<T = Vec<u8>> {
/// File name.
pub name: String,
/// File content or content hash.
pub content: T,
}
impl Embed<Vec<u8>> {
/// Get the object id of the embedded content.
pub fn oid(&self) -> Oid {
// SAFETY: This should not fail since we are using a valid object type.
git2::Oid::hash_object(git2::ObjectType::Blob, &self.content)
.expect("Embed::oid: invalid object")
.into()
}
/// Return am embed where the content is replaced by a content hash.
pub fn hashed<T: From<Oid>>(&self) -> Embed<T> {
Embed {
name: self.name.clone(),
content: T::from(self.oid()),
}
}
}
impl Embed<Oid> {
/// Get the object id of the embedded content.
pub fn oid(&self) -> Oid {
self.content
}
}

View File

@ -1,7 +1,7 @@
// Copyright © 2021 The Radicle Link Contributors // Copyright © 2021 The Radicle Link Contributors
use std::collections::BTreeSet;
use std::ops::ControlFlow; use std::ops::ControlFlow;
use std::{collections::BTreeSet, convert::TryInto};
use git_ext::Oid; use git_ext::Oid;
use radicle_dag::Dag; use radicle_dag::Dag;
@ -135,8 +135,8 @@ impl ChangeGraph {
self.graph.tips().map(|(_, change)| *change.id()).collect() self.graph.tips().map(|(_, change)| *change.id()).collect()
} }
pub(crate) fn number_of_nodes(&self) -> u64 { pub(crate) fn number_of_nodes(&self) -> usize {
self.graph.len().try_into().unwrap() self.graph.len()
} }
} }

View File

@ -1,5 +1,6 @@
// Copyright © 2021 The Radicle Link Contributors // Copyright © 2021 The Radicle Link Contributors
#![warn(clippy::unwrap_used)]
//! # Collaborative Objects //! # Collaborative Objects
//! //!
//! Collaborative objects are graphs of CRDTs. The current CRDTs that //! Collaborative objects are graphs of CRDTs. The current CRDTs that
@ -80,7 +81,7 @@ mod change_graph;
mod trailers; mod trailers;
pub mod change; pub mod change;
pub use change::store::{Manifest, Version}; pub use change::store::{Embed, Manifest, Version};
pub use change::Change; pub use change::Change;
pub mod history; pub mod history;
@ -98,9 +99,11 @@ pub use object::{
}; };
#[cfg(test)] #[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test; mod test;
#[cfg(test)] #[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests; mod tests;
/// The `Store` is an aggregation of the different types of storage /// The `Store` is an aggregation of the different types of storage

View File

@ -2,6 +2,7 @@
use nonempty::NonEmpty; use nonempty::NonEmpty;
use crate::Embed;
use crate::Store; use crate::Store;
use super::*; use super::*;
@ -14,17 +15,20 @@ pub struct Create {
pub type_name: TypeName, pub type_name: TypeName,
/// The message to add when creating this object. /// The message to add when creating this object.
pub message: String, pub message: String,
/// Embedded content.
pub embeds: Vec<Embed>,
/// COB version. /// COB version.
pub version: Version, pub version: Version,
} }
impl Create { impl Create {
fn template(&self) -> change::Template<git_ext::Oid> { fn template(self) -> change::Template<git_ext::Oid> {
change::Template { change::Template {
type_name: self.type_name.clone(), type_name: self.type_name,
tips: Vec::new(), tips: Vec::new(),
message: self.message.clone(), message: self.message,
contents: self.contents.clone(), embeds: self.embeds,
contents: self.contents,
} }
} }
} }
@ -59,14 +63,15 @@ where
S: Store<I>, S: Store<I>,
G: crypto::Signer, G: crypto::Signer,
{ {
let Create { type_name, .. } = &args; let type_name = args.type_name.clone();
let version = args.version;
let init_change = storage let init_change = storage
.store(resource, parents, signer, args.template()) .store(resource, parents, signer, args.template())
.map_err(error::Create::from)?; .map_err(error::Create::from)?;
let object_id = init_change.id().into(); let object_id = init_change.id().into();
storage storage
.update(identifier, type_name, &object_id, &init_change) .update(identifier, &type_name, &object_id, &init_change)
.map_err(|err| error::Create::Refs { err: Box::new(err) })?; .map_err(|err| error::Create::Refs { err: Box::new(err) })?;
let history = History::new_from_root( let history = History::new_from_root(
@ -79,7 +84,7 @@ where
); );
Ok(CollaborativeObject { Ok(CollaborativeObject {
manifest: Manifest::new(args.type_name, args.version), manifest: Manifest::new(type_name, version),
history, history,
id: object_id, id: object_id,
}) })

View File

@ -17,7 +17,7 @@ pub struct ChangeGraphInfo {
/// The ID of the object /// The ID of the object
pub object_id: ObjectId, pub object_id: ObjectId,
/// The number of nodes in the change graph of the object /// The number of nodes in the change graph of the object
pub number_of_nodes: u64, pub number_of_nodes: usize,
/// The "tips" of the change graph, i.e the object IDs pointed to by /// The "tips" of the change graph, i.e the object IDs pointed to by
/// references to the object /// references to the object
pub tips: BTreeSet<Oid>, pub tips: BTreeSet<Oid>,

View File

@ -4,8 +4,8 @@ use git_ext::Oid;
use nonempty::NonEmpty; use nonempty::NonEmpty;
use crate::{ use crate::{
change, change_graph::ChangeGraph, history::EntryId, CollaborativeObject, ObjectId, Store, change, change_graph::ChangeGraph, history::EntryId, CollaborativeObject, Embed, ObjectId,
TypeName, Store, TypeName,
}; };
use super::error; use super::error;
@ -31,6 +31,8 @@ pub struct Update {
pub type_name: TypeName, pub type_name: TypeName,
/// The message to add when updating this object. /// The message to add when updating this object.
pub message: String, pub message: String,
/// Embedded files.
pub embeds: Vec<Embed>,
} }
/// Update an existing [`CollaborativeObject`]. /// Update an existing [`CollaborativeObject`].
@ -67,6 +69,7 @@ where
let Update { let Update {
type_name: ref typename, type_name: ref typename,
object_id, object_id,
embeds,
changes, changes,
message, message,
} = args; } = args;
@ -85,6 +88,7 @@ where
signer, signer,
change::Template { change::Template {
tips: object.tips().iter().cloned().collect(), tips: object.tips().iter().cloned().collect(),
embeds,
contents: changes, contents: changes,
type_name: typename.clone(), type_name: typename.clone(),
message, message,

View File

@ -34,6 +34,7 @@ fn roundtrip() {
contents: nonempty!(Vec::new()), contents: nonempty!(Vec::new()),
type_name: typename.clone(), type_name: typename.clone(),
message: "creating xyz.rad.issue".to_string(), message: "creating xyz.rad.issue".to_string(),
embeds: vec![],
version: Version::default(), version: Version::default(),
}, },
) )
@ -67,6 +68,7 @@ fn list_cobs() {
contents: nonempty!(b"issue 1".to_vec()), contents: nonempty!(b"issue 1".to_vec()),
type_name: typename.clone(), type_name: typename.clone(),
message: "creating xyz.rad.issue".to_string(), message: "creating xyz.rad.issue".to_string(),
embeds: vec![],
version: Version::default(), version: Version::default(),
}, },
) )
@ -82,6 +84,7 @@ fn list_cobs() {
contents: nonempty!(b"issue 2".to_vec()), contents: nonempty!(b"issue 2".to_vec()),
type_name: typename.clone(), type_name: typename.clone(),
message: "commenting xyz.rad.issue".to_string(), message: "commenting xyz.rad.issue".to_string(),
embeds: vec![],
version: Version::default(), version: Version::default(),
}, },
) )
@ -117,6 +120,7 @@ fn update_cob() {
contents: nonempty!(Vec::new()), contents: nonempty!(Vec::new()),
type_name: typename.clone(), type_name: typename.clone(),
message: "creating xyz.rad.issue".to_string(), message: "creating xyz.rad.issue".to_string(),
embeds: vec![],
version: Version::default(), version: Version::default(),
}, },
) )
@ -136,6 +140,7 @@ fn update_cob() {
changes: nonempty!(b"issue 1".to_vec()), changes: nonempty!(b"issue 1".to_vec()),
object_id: *cob.id(), object_id: *cob.id(),
type_name: typename.clone(), type_name: typename.clone(),
embeds: vec![],
message: "commenting xyz.rad.issue".to_string(), message: "commenting xyz.rad.issue".to_string(),
}, },
) )
@ -176,6 +181,7 @@ fn traverse_cobs() {
contents: nonempty!(b"issue 1".to_vec()), contents: nonempty!(b"issue 1".to_vec()),
type_name: typename.clone(), type_name: typename.clone(),
message: "creating xyz.rad.issue".to_string(), message: "creating xyz.rad.issue".to_string(),
embeds: vec![],
version: Version::default(), version: Version::default(),
}, },
) )
@ -199,6 +205,7 @@ fn traverse_cobs() {
changes: nonempty!(b"issue 2".to_vec()), changes: nonempty!(b"issue 2".to_vec()),
object_id: *cob.id(), object_id: *cob.id(),
type_name: typename, type_name: typename,
embeds: vec![],
message: "commenting on xyz.rad.issue".to_string(), message: "commenting on xyz.rad.issue".to_string(),
}, },
) )

View File

@ -58,6 +58,8 @@ impl From<git2::Oid> for ResourceCommitTrailer {
impl From<ResourceCommitTrailer> for Trailer<'_> { impl From<ResourceCommitTrailer> for Trailer<'_> {
fn from(containing: ResourceCommitTrailer) -> Self { fn from(containing: ResourceCommitTrailer) -> Self {
Trailer { Trailer {
// SAFETY: "Rad-Resource" is a valid `Token`.
#[allow(clippy::unwrap_used)]
token: Token::try_from("Rad-Resource").unwrap(), token: Token::try_from("Rad-Resource").unwrap(),
value: containing.0.to_string().into(), value: containing.0.to_string().into(),
} }

View File

@ -507,6 +507,7 @@ async fn issue_create_handler(
issue.description, issue.description,
&issue.labels, &issue.labels,
&issue.assignees, &issue.assignees,
[],
&signer, &signer,
) )
.map_err(Error::from)?; .map_err(Error::from)?;
@ -546,9 +547,9 @@ async fn issue_update_handler(
issue::Action::Edit { title } => { issue::Action::Edit { title } => {
issue.edit(title, &signer)?; issue.edit(title, &signer)?;
} }
issue::Action::Comment { body, reply_to } => { issue::Action::Comment { body, reply_to, .. } => {
if let Some(to) = reply_to { if let Some(to) = reply_to {
issue.comment(body, to, &signer)?; issue.comment(body, to, [], &signer)?;
} else { } else {
return Err(Error::BadRequest("`replyTo` missing".to_owned())); return Err(Error::BadRequest("`replyTo` missing".to_owned()));
} }

View File

@ -185,6 +185,7 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
"Change 'hello world' to 'hello everyone'".to_string(), "Change 'hello world' to 'hello everyone'".to_string(),
&[], &[],
&[], &[],
[],
signer, signer,
) )
.unwrap(); .unwrap();

View File

@ -315,7 +315,7 @@ impl<G: Signer + cyphernet::Ecdh> NodeHandle<G> {
let repo = self.storage.repository(rid).unwrap(); let repo = self.storage.repository(rid).unwrap();
let mut issues = issue::Issues::open(&repo).unwrap(); let mut issues = issue::Issues::open(&repo).unwrap();
*issues *issues
.create(title, desc, &[], &[], &self.signer) .create(title, desc, &[], &[], [], &self.signer)
.unwrap() .unwrap()
.id() .id()
} }

View File

@ -12,7 +12,7 @@ pub mod test;
pub use cob::{ pub use cob::{
change, history::EntryId, object, object::collaboration::error, CollaborativeObject, Contents, change, history::EntryId, object, object::collaboration::error, CollaborativeObject, Contents,
Create, Entry, History, Manifest, ObjectId, Store, TypeName, Update, Updated, Version, Create, Embed, Entry, History, Manifest, ObjectId, Store, TypeName, Update, Updated, Version,
}; };
pub use cob::{create, get, list, remove, update}; pub use cob::{create, get, list, remove, update};
pub use common::*; pub use common::*;

View File

@ -4,6 +4,7 @@ use std::str::FromStr;
use localtime::LocalTime; use localtime::LocalTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::git_ext::Oid;
use crate::prelude::*; use crate::prelude::*;
/// Timestamp used for COB operations. /// Timestamp used for COB operations.
@ -226,6 +227,57 @@ impl<'a> Deserialize<'a> for Color {
} }
} }
/// A URI.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Uri(String);
impl Uri {
/// Get a string reference to the URI.
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl From<Oid> for Uri {
fn from(oid: Oid) -> Self {
Uri(format!("git:{oid}"))
}
}
impl TryFrom<&Uri> for Oid {
type Error = Uri;
fn try_from(value: &Uri) -> Result<Self, Self::Error> {
if let Some(oid) = value.as_str().strip_prefix("git:") {
let oid = oid.parse().map_err(|_| value.clone())?;
return Ok(oid);
}
Err(value.clone())
}
}
impl std::fmt::Display for Uri {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::str::FromStr for Uri {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if !s.chars().all(|c| c.is_ascii()) {
return Err(s.to_owned());
}
if !s.contains(':') {
return Err(s.to_owned());
}
Ok(Self(s.to_owned()))
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;

View File

@ -320,7 +320,7 @@ impl store::FromHistory for Proposal {
type Error = ApplyError; type Error = ApplyError;
fn type_name() -> &'static TypeName { fn type_name() -> &'static TypeName {
&*TYPENAME &TYPENAME
} }
fn validate(&self) -> Result<(), Self::Error> { fn validate(&self) -> Result<(), Self::Error> {
@ -390,6 +390,7 @@ impl store::FromHistory for Proposal {
body, body,
reply_to, reply_to,
None, None,
vec![],
)?; )?;
} }
} }
@ -399,7 +400,14 @@ impl store::FromHistory for Proposal {
body, body,
} => { } => {
if let Some(revision) = lookup::revision(self, &revision)? { if let Some(revision) = lookup::revision(self, &revision)? {
thread::edit(&mut revision.discussion, op.id, comment, op.timestamp, body)?; thread::edit(
&mut revision.discussion,
op.id,
comment,
op.timestamp,
body,
vec![],
)?;
} }
} }
Action::RevisionCommentRedact { revision, comment } => { Action::RevisionCommentRedact { revision, comment } => {

View File

@ -7,12 +7,12 @@ use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use crate::cob; use crate::cob;
use crate::cob::common::{Author, Label, Reaction, Timestamp}; use crate::cob::common::{Author, Label, Reaction, Timestamp, Uri};
use crate::cob::store::Transaction; use crate::cob::store::Transaction;
use crate::cob::store::{FromHistory as _, HistoryAction}; use crate::cob::store::{FromHistory as _, HistoryAction};
use crate::cob::thread; use crate::cob::thread;
use crate::cob::thread::{CommentId, Thread}; use crate::cob::thread::{CommentId, Thread};
use crate::cob::{store, EntryId, ObjectId, TypeName}; use crate::cob::{store, Embed, EntryId, ObjectId, TypeName};
use crate::crypto::Signer; use crate::crypto::Signer;
use crate::prelude::{Did, ReadRepository}; use crate::prelude::{Did, ReadRepository};
use crate::storage::WriteRepository; use crate::storage::WriteRepository;
@ -109,7 +109,7 @@ impl store::FromHistory for Issue {
type Error = Error; type Error = Error;
fn type_name() -> &'static TypeName { fn type_name() -> &'static TypeName {
&*TYPENAME &TYPENAME
} }
fn validate(&self) -> Result<(), Self::Error> { fn validate(&self) -> Result<(), Self::Error> {
@ -154,7 +154,11 @@ impl store::FromHistory for Issue {
Action::Label { labels } => { Action::Label { labels } => {
self.labels = BTreeSet::from_iter(labels); self.labels = BTreeSet::from_iter(labels);
} }
Action::Comment { body, reply_to } => { Action::Comment {
body,
reply_to,
embeds,
} => {
thread::comment( thread::comment(
&mut self.thread, &mut self.thread,
op.id, op.id,
@ -163,10 +167,11 @@ impl store::FromHistory for Issue {
body, body,
reply_to, reply_to,
None, None,
embeds,
)?; )?;
} }
Action::CommentEdit { id, body } => { Action::CommentEdit { id, body, embeds } => {
thread::edit(&mut self.thread, op.id, id, op.timestamp, body)?; thread::edit(&mut self.thread, op.id, id, op.timestamp, body, embeds)?;
} }
Action::CommentRedact { id } => { Action::CommentRedact { id } => {
thread::redact(&mut self.thread, op.id, id)?; thread::redact(&mut self.thread, op.id, id)?;
@ -252,10 +257,19 @@ impl store::Transaction<Issue> {
} }
/// Edit an issue comment. /// Edit an issue comment.
pub fn edit_comment(&mut self, id: CommentId, body: impl ToString) -> Result<(), store::Error> { pub fn edit_comment(
&mut self,
id: CommentId,
body: impl ToString,
embeds: Vec<Embed>,
) -> Result<(), store::Error> {
let hashed = embeds.iter().map(|e| e.hashed()).collect();
self.embed(embeds)?;
self.push(Action::CommentEdit { self.push(Action::CommentEdit {
id, id,
body: body.to_string(), body: body.to_string(),
embeds: hashed,
}) })
} }
@ -276,10 +290,15 @@ impl store::Transaction<Issue> {
&mut self, &mut self,
body: S, body: S,
reply_to: CommentId, reply_to: CommentId,
embeds: Vec<Embed>,
) -> Result<(), store::Error> { ) -> Result<(), store::Error> {
let hashed = embeds.iter().map(|e| e.hashed()).collect();
self.embed(embeds)?;
self.push(Action::Comment { self.push(Action::Comment {
body: body.to_string(), body: body.to_string(),
reply_to: Some(reply_to), reply_to: Some(reply_to),
embeds: hashed,
}) })
} }
@ -307,10 +326,19 @@ impl store::Transaction<Issue> {
//////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////
/// Create the issue thread. /// Create the issue thread.
fn thread<S: ToString>(&mut self, body: S) -> Result<(), store::Error> { fn thread<S: ToString>(
&mut self,
body: S,
embeds: impl IntoIterator<Item = Embed>,
) -> Result<(), store::Error> {
let embeds = embeds.into_iter().collect::<Vec<_>>();
let hashed = embeds.iter().map(|e| e.hashed()).collect();
self.embed(embeds)?;
self.push(Action::Comment { self.push(Action::Comment {
body: body.to_string(), body: body.to_string(),
reply_to: None, reply_to: None,
embeds: hashed,
}) })
} }
} }
@ -367,6 +395,7 @@ where
pub fn edit_description<G: Signer>( pub fn edit_description<G: Signer>(
&mut self, &mut self,
description: impl ToString, description: impl ToString,
embeds: impl IntoIterator<Item = Embed>,
signer: &G, signer: &G,
) -> Result<EntryId, Error> { ) -> Result<EntryId, Error> {
let Some((id, _)) = self.thread.comments().next() else { let Some((id, _)) = self.thread.comments().next() else {
@ -374,7 +403,7 @@ where
}; };
let id = *id; let id = *id;
self.transaction("Edit description", signer, |tx| { self.transaction("Edit description", signer, |tx| {
tx.edit_comment(id, description) tx.edit_comment(id, description, embeds.into_iter().collect())
}) })
} }
@ -388,13 +417,16 @@ where
&mut self, &mut self,
body: S, body: S,
reply_to: CommentId, reply_to: CommentId,
embeds: impl IntoIterator<Item = Embed>,
signer: &G, signer: &G,
) -> Result<EntryId, Error> { ) -> Result<EntryId, Error> {
assert!( assert!(
self.thread.comment(&reply_to).is_some(), self.thread.comment(&reply_to).is_some(),
"Comment {reply_to} not found" "Comment {reply_to} not found"
); );
self.transaction("Comment", signer, |tx| tx.comment(body, reply_to)) self.transaction("Comment", signer, |tx| {
tx.comment(body, reply_to, embeds.into_iter().collect())
})
} }
/// Label an issue. /// Label an issue.
@ -502,10 +534,11 @@ where
description: impl ToString, description: impl ToString,
labels: &[Label], labels: &[Label],
assignees: &[Did], assignees: &[Did],
embeds: impl IntoIterator<Item = Embed>,
signer: &G, signer: &G,
) -> Result<IssueMut<'a, 'g, R>, Error> { ) -> Result<IssueMut<'a, 'g, R>, Error> {
let (id, issue) = Transaction::initial("Create issue", &mut self.raw, signer, |tx| { let (id, issue) = Transaction::initial("Create issue", &mut self.raw, signer, |tx| {
tx.thread(description)?; tx.thread(description, embeds)?;
tx.assign(assignees.to_owned())?; tx.assign(assignees.to_owned())?;
tx.edit(title)?; tx.edit(title)?;
tx.label(labels.to_owned())?; tx.label(labels.to_owned())?;
@ -573,11 +606,21 @@ pub enum Action {
/// Should be the root [`CommentId`] if it's a top-level comment. /// Should be the root [`CommentId`] if it's a top-level comment.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
reply_to: Option<CommentId>, reply_to: Option<CommentId>,
/// Embeded content.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
embeds: Vec<Embed<Uri>>,
}, },
/// Edit a comment. /// Edit a comment.
#[serde(rename = "comment.edit")] #[serde(rename = "comment.edit")]
CommentEdit { id: CommentId, body: String }, CommentEdit {
/// Comment being edited.
id: CommentId,
/// New value for the comment body.
body: String,
/// New value for the embeds list.
embeds: Vec<Embed<Uri>>,
},
/// Redact a change. Not all changes can be redacted. /// Redact a change. Not all changes can be redacted.
#[serde(rename = "comment.redact")] #[serde(rename = "comment.redact")]
@ -600,6 +643,7 @@ mod test {
use super::*; use super::*;
use crate::cob::{ActorId, Reaction}; use crate::cob::{ActorId, Reaction};
use crate::git::Oid;
use crate::test; use crate::test;
use crate::test::arbitrary; use crate::test::arbitrary;
@ -611,7 +655,14 @@ mod test {
let mut eve_issues = Issues::open(&*t.eve.repo).unwrap(); let mut eve_issues = Issues::open(&*t.eve.repo).unwrap();
let mut issue_alice = issues_alice let mut issue_alice = issues_alice
.create("Alice Issue", "Alice's comment", &[], &[], &t.alice.signer) .create(
"Alice Issue",
"Alice's comment",
&[],
&[],
[],
&t.alice.signer,
)
.unwrap(); .unwrap();
let id = *issue_alice.id(); let id = *issue_alice.id();
@ -622,10 +673,10 @@ mod test {
let mut issue_bob = bob_issues.get_mut(&id).unwrap(); let mut issue_bob = bob_issues.get_mut(&id).unwrap();
issue_bob issue_bob
.comment("Bob's reply", id.into(), &t.bob.signer) .comment("Bob's reply", id.into(), vec![], &t.bob.signer)
.unwrap(); .unwrap();
issue_alice issue_alice
.comment("Alice's reply", id.into(), &t.alice.signer) .comment("Alice's reply", id.into(), vec![], &t.alice.signer)
.unwrap(); .unwrap();
assert_eq!(issue_bob.comments().count(), 2); assert_eq!(issue_bob.comments().count(), 2);
@ -653,7 +704,7 @@ mod test {
t.eve.repo.fetch(&t.alice); t.eve.repo.fetch(&t.alice);
let eve_reply = issue_eve let eve_reply = issue_eve
.comment("Eve's reply", id.into(), &t.eve.signer) .comment("Eve's reply", id.into(), vec![], &t.eve.signer)
.unwrap(); .unwrap();
t.bob.repo.fetch(&t.eve); t.bob.repo.fetch(&t.eve);
@ -698,6 +749,7 @@ mod test {
"Blah blah blah.", "Blah blah blah.",
&[], &[],
&[assignee], &[assignee],
[],
&node.signer, &node.signer,
) )
.unwrap(); .unwrap();
@ -736,6 +788,7 @@ mod test {
"Blah blah blah.", "Blah blah blah.",
&[], &[],
&[assignee], &[assignee],
[],
&node.signer, &node.signer,
) )
.unwrap(); .unwrap();
@ -760,7 +813,14 @@ mod test {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap(); let mut issues = Issues::open(&*repo).unwrap();
let created = issues let created = issues
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer) .create(
"My first issue",
"Blah blah blah.",
&[],
&[],
[],
&node.signer,
)
.unwrap(); .unwrap();
let (id, created) = (created.id, created.issue); let (id, created) = (created.id, created.issue);
@ -779,7 +839,14 @@ mod test {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap(); let mut issues = Issues::open(&*repo).unwrap();
let mut issue = issues let mut issue = issues
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer) .create(
"My first issue",
"Blah blah blah.",
&[],
&[],
[],
&node.signer,
)
.unwrap(); .unwrap();
issue issue
@ -820,6 +887,7 @@ mod test {
"Blah blah blah.", "Blah blah blah.",
&[], &[],
&[assignee, assignee_two], &[assignee, assignee_two],
[],
&node.signer, &node.signer,
) )
.unwrap(); .unwrap();
@ -839,7 +907,14 @@ mod test {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap(); let mut issues = Issues::open(&*repo).unwrap();
let mut issue = issues let mut issue = issues
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer) .create(
"My first issue",
"Blah blah blah.",
&[],
&[],
[],
&node.signer,
)
.unwrap(); .unwrap();
issue.edit("Sorry typo", &node.signer).unwrap(); issue.edit("Sorry typo", &node.signer).unwrap();
@ -856,11 +931,18 @@ mod test {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap(); let mut issues = Issues::open(&*repo).unwrap();
let mut issue = issues let mut issue = issues
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer) .create(
"My first issue",
"Blah blah blah.",
&[],
&[],
[],
&node.signer,
)
.unwrap(); .unwrap();
issue issue
.edit_description("Bob Loblaw law blog", &node.signer) .edit_description("Bob Loblaw law blog", vec![], &node.signer)
.unwrap(); .unwrap();
let id = issue.id; let id = issue.id;
@ -875,7 +957,14 @@ mod test {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap(); let mut issues = Issues::open(&*repo).unwrap();
let mut issue = issues let mut issue = issues
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer) .create(
"My first issue",
"Blah blah blah.",
&[],
&[],
[],
&node.signer,
)
.unwrap(); .unwrap();
let (comment, _) = issue.root(); let (comment, _) = issue.root();
@ -897,13 +986,24 @@ mod test {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap(); let mut issues = Issues::open(&*repo).unwrap();
let mut issue = issues let mut issue = issues
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer) .create(
"My first issue",
"Blah blah blah.",
&[],
&[],
[],
&node.signer,
)
.unwrap(); .unwrap();
let (root, _) = issue.root(); let (root, _) = issue.root();
let root = *root; let root = *root;
let c1 = issue.comment("Hi hi hi.", root, &node.signer).unwrap(); let c1 = issue
let c2 = issue.comment("Ha ha ha.", root, &node.signer).unwrap(); .comment("Hi hi hi.", root, vec![], &node.signer)
.unwrap();
let c2 = issue
.comment("Ha ha ha.", root, vec![], &node.signer)
.unwrap();
let id = issue.id; let id = issue.id;
let mut issue = issues.get_mut(&id).unwrap(); let mut issue = issues.get_mut(&id).unwrap();
@ -913,10 +1013,14 @@ mod test {
assert_eq!(reply1.body(), "Hi hi hi."); assert_eq!(reply1.body(), "Hi hi hi.");
assert_eq!(reply2.body(), "Ha ha ha."); assert_eq!(reply2.body(), "Ha ha ha.");
issue.comment("Re: Hi.", c1, &node.signer).unwrap(); issue.comment("Re: Hi.", c1, vec![], &node.signer).unwrap();
issue.comment("Re: Ha.", c2, &node.signer).unwrap(); issue.comment("Re: Ha.", c2, vec![], &node.signer).unwrap();
issue.comment("Re: Ha. Ha.", c2, &node.signer).unwrap(); issue
issue.comment("Re: Ha. Ha. Ha.", c2, &node.signer).unwrap(); .comment("Re: Ha. Ha.", c2, vec![], &node.signer)
.unwrap();
issue
.comment("Re: Ha. Ha. Ha.", c2, vec![], &node.signer)
.unwrap();
let issue = issues.get(&id).unwrap().unwrap(); let issue = issues.get(&id).unwrap().unwrap();
@ -942,6 +1046,7 @@ mod test {
"Blah blah blah.", "Blah blah blah.",
&[ux_label.clone()], &[ux_label.clone()],
&[], &[],
[],
&node.signer, &node.signer,
) )
.unwrap(); .unwrap();
@ -971,15 +1076,26 @@ mod test {
let author = *node.signer.public_key(); let author = *node.signer.public_key();
let mut issues = Issues::open(&*repo).unwrap(); let mut issues = Issues::open(&*repo).unwrap();
let mut issue = issues let mut issue = issues
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer) .create(
"My first issue",
"Blah blah blah.",
&[],
&[],
[],
&node.signer,
)
.unwrap(); .unwrap();
// The root thread op id is always the same. // The root thread op id is always the same.
let (c0, _) = issue.root(); let (c0, _) = issue.root();
let c0 = *c0; let c0 = *c0;
issue.comment("Ho ho ho.", c0, &node.signer).unwrap(); issue
issue.comment("Ha ha ha.", c0, &node.signer).unwrap(); .comment("Ho ho ho.", c0, vec![], &node.signer)
.unwrap();
issue
.comment("Ha ha ha.", c0, vec![], &node.signer)
.unwrap();
let id = issue.id; let id = issue.id;
let issue = issues.get(&id).unwrap().unwrap(); let issue = issues.get(&id).unwrap().unwrap();
@ -1017,13 +1133,13 @@ mod test {
let mut issues = Issues::open(&*repo).unwrap(); let mut issues = Issues::open(&*repo).unwrap();
issues issues
.create("First", "Blah", &[], &[], &node.signer) .create("First", "Blah", &[], &[], [], &node.signer)
.unwrap(); .unwrap();
issues issues
.create("Second", "Blah", &[], &[], &node.signer) .create("Second", "Blah", &[], &[], [], &node.signer)
.unwrap(); .unwrap();
issues issues
.create("Third", "Blah", &[], &[], &node.signer) .create("Third", "Blah", &[], &[], [], &node.signer)
.unwrap(); .unwrap();
let issues = issues let issues = issues
@ -1050,6 +1166,7 @@ mod test {
"Blah blah blah.\nYah yah yah", "Blah blah blah.\nYah yah yah",
&[], &[],
&[], &[],
[],
&node.signer, &node.signer,
) )
.unwrap(); .unwrap();
@ -1064,4 +1181,106 @@ mod test {
assert_eq!(issue.comments().count(), 1); assert_eq!(issue.comments().count(), 1);
assert_eq!(issue.state(), &State::Open); assert_eq!(issue.state(), &State::Open);
} }
#[test]
fn test_embeds() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let embed1 = Embed {
name: String::from("example.html"),
content: b"<html>Hello World!</html>".to_vec(),
};
let embed2 = Embed {
name: String::from("style.css"),
content: b"body { color: red }".to_vec(),
};
let embed3 = Embed {
name: String::from("bin"),
content: vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
};
let mut issue = issues
.create(
"My first issue",
"Blah blah blah.",
&[],
&[],
[embed1.clone(), embed2.clone()],
&node.signer,
)
.unwrap();
issue
.comment(
"Here's a binary file",
issue.id.into(),
[embed3.clone()],
&node.signer,
)
.unwrap();
issue.reload().unwrap();
let (_, c0) = issue.thread().comments().next().unwrap();
let (_, c1) = issue.thread().comments().next_back().unwrap();
let e1 = &c0.embeds()[0];
let e2 = &c0.embeds()[1];
let e3 = &c1.embeds()[0];
let b1 = repo.blob(Oid::try_from(&e1.content).unwrap()).unwrap();
let b2 = repo.blob(Oid::try_from(&e2.content).unwrap()).unwrap();
let b3 = repo.blob(Oid::try_from(&e3.content).unwrap()).unwrap();
assert_eq!(b1.content(), &embed1.content);
assert_eq!(b2.content(), &embed2.content);
assert_eq!(b3.content(), &embed3.content);
assert_eq!(b1.is_binary(), false);
assert_eq!(b2.is_binary(), false);
assert_eq!(b3.is_binary(), true);
}
#[test]
fn test_embeds_edit() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let embed1 = Embed {
name: String::from("example.html"),
content: b"<html>Hello World!</html>".to_vec(),
};
let embed1_edited = Embed {
name: String::from("example.html"),
content: b"<html>Hello Radicle!</html>".to_vec(),
};
let embed2 = Embed {
name: String::from("style.css"),
content: b"body { color: red }".to_vec(),
};
let mut issue = issues
.create(
"My first issue",
"Blah blah blah.",
&[],
&[],
[embed1.clone(), embed2.clone()],
&node.signer,
)
.unwrap();
issue.reload().unwrap();
issue
.edit_description("My first issue", [embed1_edited.clone()], &node.signer)
.unwrap();
issue.reload().unwrap();
let (_, c0) = issue.thread().comments().next().unwrap();
assert_eq!(c0.embeds().len(), 1);
let e1 = &c0.embeds()[0];
let b1 = repo.blob(Oid::try_from(&e1.content).unwrap()).unwrap();
assert_eq!(e1.content, Uri::from(embed1_edited.oid()));
assert_eq!(b1.content(), &embed1_edited.content);
}
} }

View File

@ -28,7 +28,7 @@ impl store::FromHistory for Issue {
type Error = Error; type Error = Error;
fn type_name() -> &'static TypeName { fn type_name() -> &'static TypeName {
&*issue::TYPENAME &issue::TYPENAME
} }
fn validate(&self) -> Result<(), Self::Error> { fn validate(&self) -> Result<(), Self::Error> {

View File

@ -103,7 +103,7 @@ impl store::FromHistory for Patch {
type Error = Error; type Error = Error;
fn type_name() -> &'static TypeName { fn type_name() -> &'static TypeName {
&*TYPENAME &TYPENAME
} }
fn validate(&self) -> Result<(), Self::Error> { fn validate(&self) -> Result<(), Self::Error> {
@ -242,6 +242,7 @@ impl store::FromHistory for Patch {
comment, comment,
timestamp, timestamp,
body, body,
vec![],
)?; )?;
} }
} }
@ -277,6 +278,7 @@ impl store::FromHistory for Patch {
body, body,
None, None,
Some(location), Some(location),
vec![],
)?; )?;
} }
} }

View File

@ -449,7 +449,7 @@ impl store::FromHistory for Patch {
type Error = Error; type Error = Error;
fn type_name() -> &'static TypeName { fn type_name() -> &'static TypeName {
&*TYPENAME &TYPENAME
} }
fn validate(&self) -> Result<(), Self::Error> { fn validate(&self) -> Result<(), Self::Error> {
@ -626,7 +626,14 @@ impl store::FromHistory for Patch {
body, body,
} => { } => {
if let Some(review) = lookup::review(self, &review)? { if let Some(review) = lookup::review(self, &review)? {
thread::edit(&mut review.comments, op.id, comment, timestamp, body)?; thread::edit(
&mut review.comments,
op.id,
comment,
timestamp,
body,
vec![],
)?;
} }
} }
Action::ReviewCommentResolve { .. } => { Action::ReviewCommentResolve { .. } => {
@ -650,6 +657,7 @@ impl store::FromHistory for Patch {
body, body,
reply_to, reply_to,
location, location,
vec![],
)?; )?;
} }
} }
@ -739,6 +747,7 @@ impl store::FromHistory for Patch {
body, body,
reply_to, reply_to,
None, None,
vec![],
)?; )?;
} }
} }
@ -748,7 +757,14 @@ impl store::FromHistory for Patch {
body, body,
} => { } => {
if let Some(revision) = lookup::revision(self, &revision)? { if let Some(revision) = lookup::revision(self, &revision)? {
thread::edit(&mut revision.discussion, op.id, comment, op.timestamp, body)?; thread::edit(
&mut revision.discussion,
op.id,
comment,
op.timestamp,
body,
vec![],
)?;
} }
} }
Action::RevisionCommentRedact { revision, comment } => { Action::RevisionCommentRedact { revision, comment } => {

View File

@ -10,7 +10,9 @@ use serde::{Deserialize, Serialize};
use crate::cob::common::Timestamp; use crate::cob::common::Timestamp;
use crate::cob::op::Op; use crate::cob::op::Op;
use crate::cob::{ActorId, Create, EntryId, History, ObjectId, TypeName, Update, Updated, Version}; use crate::cob::{
ActorId, Create, Embed, EntryId, History, ObjectId, TypeName, Update, Updated, Version,
};
use crate::git; use crate::git;
use crate::prelude::*; use crate::prelude::*;
use crate::storage::git as storage; use crate::storage::git as storage;
@ -165,6 +167,7 @@ where
object_id: ObjectId, object_id: ObjectId,
message: &str, message: &str,
actions: impl Into<NonEmpty<T::Action>>, actions: impl Into<NonEmpty<T::Action>>,
embeds: Vec<Embed>,
signer: &G, signer: &G,
) -> Result<Updated, Error> { ) -> Result<Updated, Error> {
let actions = actions.into(); let actions = actions.into();
@ -180,6 +183,7 @@ where
object_id, object_id,
type_name: T::type_name().clone(), type_name: T::type_name().clone(),
message: message.to_owned(), message: message.to_owned(),
embeds,
changes, changes,
}, },
)?; )?;
@ -194,6 +198,7 @@ where
&self, &self,
message: &str, message: &str,
actions: impl Into<NonEmpty<T::Action>>, actions: impl Into<NonEmpty<T::Action>>,
embeds: Vec<Embed>,
signer: &G, signer: &G,
) -> Result<(ObjectId, T), Error> { ) -> Result<(ObjectId, T), Error> {
let actions = actions.into(); let actions = actions.into();
@ -209,6 +214,7 @@ where
type_name: T::type_name().clone(), type_name: T::type_name().clone(),
version: Version::default(), version: Version::default(),
message: message.to_owned(), message: message.to_owned(),
embeds,
contents, contents,
}, },
)?; )?;
@ -288,6 +294,7 @@ where
pub struct Transaction<T: FromHistory> { pub struct Transaction<T: FromHistory> {
actor: ActorId, actor: ActorId,
actions: Vec<T::Action>, actions: Vec<T::Action>,
embeds: Vec<Embed>,
} }
impl<T: FromHistory> Transaction<T> { impl<T: FromHistory> Transaction<T> {
@ -296,6 +303,7 @@ impl<T: FromHistory> Transaction<T> {
Self { Self {
actor, actor,
actions: Vec::new(), actions: Vec::new(),
embeds: Vec::new(),
} }
} }
@ -313,15 +321,13 @@ impl<T: FromHistory> Transaction<T> {
T::Action: Serialize + Clone, T::Action: Serialize + Clone,
{ {
let actor = *signer.public_key(); let actor = *signer.public_key();
let mut tx = Transaction { let mut tx = Transaction::new(actor);
actor,
actions: Vec::new(),
};
operations(&mut tx)?; operations(&mut tx)?;
let actions = NonEmpty::from_vec(tx.actions) let actions = NonEmpty::from_vec(tx.actions)
.expect("Transaction::initial: transaction must contain at least one operation"); .expect("Transaction::initial: transaction must contain at least one operation");
let (id, cob) = store.create(message, actions, signer)?; let (id, cob) = store.create(message, actions, tx.embeds, signer)?;
Ok((id, cob)) Ok((id, cob))
} }
@ -333,6 +339,13 @@ impl<T: FromHistory> Transaction<T> {
Ok(()) Ok(())
} }
/// Embed media into the transaction.
pub fn embed(&mut self, embeds: impl IntoIterator<Item = Embed>) -> Result<(), Error> {
self.embeds.extend(embeds);
Ok(())
}
/// Commit transaction. /// Commit transaction.
/// ///
/// Returns an operation that can be applied onto an in-memory state. /// Returns an operation that can be applied onto an in-memory state.
@ -353,7 +366,7 @@ impl<T: FromHistory> Transaction<T> {
head, head,
object, object,
parents, parents,
} = store.update(id, msg, actions.clone(), signer)?; } = store.update(id, msg, actions.clone(), self.embeds, signer)?;
let id = EntryId::from(head); let id = EntryId::from(head);
let author = self.actor; let author = self.actor;
let timestamp = Timestamp::from_secs(object.history().timestamp()); let timestamp = Timestamp::from_secs(object.history().timestamp());

View File

@ -7,8 +7,8 @@ use serde::{ser::SerializeStruct, Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use crate::cob; use crate::cob;
use crate::cob::common::{Reaction, Timestamp}; use crate::cob::common::{Reaction, Timestamp, Uri};
use crate::cob::{ActorId, EntryId, Op}; use crate::cob::{ActorId, Embed, EntryId, Op};
use crate::prelude::ReadRepository; use crate::prelude::ReadRepository;
/// Type name of a thread, as well as the domain for all thread operations. /// Type name of a thread, as well as the domain for all thread operations.
@ -49,6 +49,8 @@ pub struct Edit {
pub timestamp: Timestamp, pub timestamp: Timestamp,
/// Edit contents. Replaces previous edits. /// Edit contents. Replaces previous edits.
pub body: String, pub body: String,
/// Edit embed list.
pub embeds: Vec<Embed<Uri>>,
} }
/// A comment on a discussion thread. /// A comment on a discussion thread.
@ -72,7 +74,7 @@ impl<T: Serialize> Serialize for Comment<T> {
where where
S: serde::ser::Serializer, S: serde::ser::Serializer,
{ {
let mut state = serializer.serialize_struct("Comment", 5)?; let mut state = serializer.serialize_struct("Comment", 6)?;
state.serialize_field("author", &self.author())?; state.serialize_field("author", &self.author())?;
if let Some(loc) = &self.location { if let Some(loc) = &self.location {
state.serialize_field("location", loc)?; state.serialize_field("location", loc)?;
@ -82,6 +84,7 @@ impl<T: Serialize> Serialize for Comment<T> {
} }
state.serialize_field("reactions", &self.reactions)?; state.serialize_field("reactions", &self.reactions)?;
state.serialize_field("body", self.body())?; state.serialize_field("body", self.body())?;
state.serialize_field("embeds", self.embeds())?;
state.end() state.end()
} }
} }
@ -93,9 +96,14 @@ impl<L> Comment<L> {
body: String, body: String,
reply_to: Option<CommentId>, reply_to: Option<CommentId>,
location: Option<L>, location: Option<L>,
embeds: Vec<Embed<Uri>>,
timestamp: Timestamp, timestamp: Timestamp,
) -> Self { ) -> Self {
let edit = Edit { body, timestamp }; let edit = Edit {
body,
embeds,
timestamp,
};
Self { Self {
author, author,
@ -139,8 +147,12 @@ impl<L> Comment<L> {
} }
/// Add an edit. /// Add an edit.
pub fn edit(&mut self, body: String, timestamp: Timestamp) { pub fn edit(&mut self, body: String, embeds: Vec<Embed<Uri>>, timestamp: Timestamp) {
self.edits.push(Edit { body, timestamp }); self.edits.push(Edit {
body,
embeds,
timestamp,
});
} }
/// Comment reactions. /// Comment reactions.
@ -152,6 +164,14 @@ impl<L> Comment<L> {
pub fn location(&self) -> Option<&L> { pub fn location(&self) -> Option<&L> {
self.location.as_ref() self.location.as_ref()
} }
/// Return the embedded media.
pub fn embeds(&self) -> &[Embed<Uri>] {
// SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`]
// constructor.
#[allow(clippy::unwrap_used)]
&self.edits.last().unwrap().embeds
}
} }
impl<T: PartialOrd> PartialOrd for Comment<T> { impl<T: PartialOrd> PartialOrd for Comment<T> {
@ -283,7 +303,7 @@ impl cob::store::FromHistory for Thread {
type Error = Error; type Error = Error;
fn type_name() -> &'static radicle_cob::TypeName { fn type_name() -> &'static radicle_cob::TypeName {
&*TYPENAME &TYPENAME
} }
fn validate(&self) -> Result<(), Self::Error> { fn validate(&self) -> Result<(), Self::Error> {
@ -301,10 +321,10 @@ impl cob::store::FromHistory for Thread {
for action in op.actions { for action in op.actions {
match action { match action {
Action::Comment { body, reply_to } => { Action::Comment { body, reply_to } => {
comment(self, id, author, timestamp, body, reply_to, None)?; comment(self, id, author, timestamp, body, reply_to, None, vec![])?;
} }
Action::Edit { id, body } => { Action::Edit { id, body } => {
edit(self, op.id, id, timestamp, body)?; edit(self, op.id, id, timestamp, body, vec![])?;
} }
Action::Redact { id } => { Action::Redact { id } => {
redact(self, op.id, id)?; redact(self, op.id, id)?;
@ -330,6 +350,7 @@ pub fn comment<L>(
body: String, body: String,
reply_to: Option<CommentId>, reply_to: Option<CommentId>,
location: Option<L>, location: Option<L>,
embeds: Vec<Embed<Uri>>,
) -> Result<(), Error> { ) -> Result<(), Error> {
if body.is_empty() { if body.is_empty() {
return Err(Error::Comment(id)); return Err(Error::Comment(id));
@ -341,7 +362,9 @@ pub fn comment<L>(
// underlying store guarantees exactly-once delivery of ops. // underlying store guarantees exactly-once delivery of ops.
thread.comments.insert( thread.comments.insert(
id, id,
Some(Comment::new(author, body, reply_to, location, timestamp)), Some(Comment::new(
author, body, reply_to, location, embeds, timestamp,
)),
); );
Ok(()) Ok(())
@ -353,6 +376,7 @@ pub fn edit<L>(
comment: EntryId, comment: EntryId,
timestamp: Timestamp, timestamp: Timestamp,
body: String, body: String,
embeds: Vec<Embed<Uri>>,
) -> Result<(), Error> { ) -> Result<(), Error> {
if body.is_empty() { if body.is_empty() {
return Err(Error::Edit(id)); return Err(Error::Edit(id));
@ -367,7 +391,7 @@ pub fn edit<L>(
// that as an error. // that as an error.
if let Some(comment) = thread.comments.get_mut(&comment) { if let Some(comment) = thread.comments.get_mut(&comment) {
if let Some(comment) = comment { if let Some(comment) = comment {
comment.edit(body, timestamp); comment.edit(body, embeds, timestamp);
} }
} else { } else {
return Err(Error::Missing(comment)); return Err(Error::Missing(comment));

View File

@ -1,5 +1,5 @@
#![allow(clippy::match_like_matches_macro)] #![allow(clippy::match_like_matches_macro)]
#![allow(clippy::explicit_auto_deref)] // TODO: This can be removed when the clippy bugs are fixed #![allow(clippy::too_many_arguments)]
#![allow(clippy::iter_nth_zero)] #![allow(clippy::iter_nth_zero)]
pub extern crate radicle_crypto as crypto; pub extern crate radicle_crypto as crypto;

View File

@ -333,6 +333,9 @@ pub trait ReadRepository: Sized {
fn blob_at<'a>(&'a self, commit: Oid, path: &'a Path) fn blob_at<'a>(&'a self, commit: Oid, path: &'a Path)
-> Result<git2::Blob<'a>, git_ext::Error>; -> Result<git2::Blob<'a>, git_ext::Error>;
/// Get a blob in this repository, given its id.
fn blob(&self, oid: Oid) -> Result<git2::Blob, git_ext::Error>;
/// Validate all remotes with [`ReadRepository::validate_remote`]. /// Validate all remotes with [`ReadRepository::validate_remote`].
fn validate(&self) -> Result<(), VerifyError> { fn validate(&self) -> Result<(), VerifyError> {
for (_, remote) in self.remotes()? { for (_, remote) in self.remotes()? {

View File

@ -357,14 +357,18 @@ impl ReadRepository for Repository {
self.backend.path() self.backend.path()
} }
fn blob_at<'a>(&'a self, oid: Oid, path: &'a Path) -> Result<git2::Blob<'a>, git::Error> { fn blob_at<'a>(&'a self, commit: Oid, path: &'a Path) -> Result<git2::Blob<'a>, git::Error> {
git::ext::Blob::At { git::ext::Blob::At {
object: oid.into(), object: commit.into(),
path, path,
} }
.get(&self.backend) .get(&self.backend)
} }
fn blob(&self, oid: Oid) -> Result<git2::Blob, git::Error> {
self.backend.find_blob(oid.into()).map_err(git::Error::from)
}
fn validate_remote(&self, remote: &Remote<Verified>) -> Result<Vec<RefString>, VerifyError> { fn validate_remote(&self, remote: &Remote<Verified>) -> Result<Vec<RefString>, VerifyError> {
// Contains a copy of the signed refs of this remote. // Contains a copy of the signed refs of this remote.
let mut signed = BTreeMap::from((*remote.refs).clone()); let mut signed = BTreeMap::from((*remote.refs).clone());

View File

@ -283,6 +283,10 @@ impl<'a> ReadRepository for DraftStore<'a> {
self.repo.blob_at(oid, path) self.repo.blob_at(oid, path)
} }
fn blob(&self, oid: git_ext::Oid) -> Result<raw::Blob, ext::Error> {
self.repo.blob(oid)
}
fn reference( fn reference(
&self, &self,
remote: &RemoteId, remote: &RemoteId,

View File

@ -178,6 +178,10 @@ impl ReadRepository for MockRepository {
Ok(true) Ok(true)
} }
fn blob(&self, _oid: Oid) -> Result<git2::Blob, git_ext::Error> {
todo!()
}
fn blob_at<'a>( fn blob_at<'a>(
&'a self, &'a self,
_oid: git_ext::Oid, _oid: git_ext::Oid,