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:
parent
f282e09dce
commit
534503d002
|
|
@ -1924,6 +1924,7 @@ dependencies = [
|
|||
"git2",
|
||||
"log",
|
||||
"nonempty 0.8.1",
|
||||
"once_cell",
|
||||
"qcheck",
|
||||
"qcheck-macros",
|
||||
"radicle-crypto",
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ fn comment(
|
|||
let (comment_id, _) = issue.comments().next().expect("root comment always exists");
|
||||
*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);
|
||||
return Ok(());
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
|||
description: Some(description),
|
||||
labels,
|
||||
} => {
|
||||
let issue = issues.create(title, description, labels.as_slice(), &[], &signer)?;
|
||||
let issue = issues.create(title, description, labels.as_slice(), &[], [], &signer)?;
|
||||
if !options.quiet {
|
||||
show_issue(&issue, issue.id())?;
|
||||
}
|
||||
|
|
@ -569,6 +569,7 @@ fn open<R: WriteRepository + cob::Store, G: Signer>(
|
|||
description.trim(),
|
||||
meta.labels.as_slice(),
|
||||
meta.assignees.as_slice(),
|
||||
[],
|
||||
signer,
|
||||
)?;
|
||||
if !options.quiet {
|
||||
|
|
@ -598,7 +599,7 @@ fn edit<R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(
|
|||
tx.edit(t)?;
|
||||
}
|
||||
if let Some(d) = description {
|
||||
tx.edit_comment(desc_id, d)?;
|
||||
tx.edit_comment(desc_id, d, vec![])?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -621,7 +622,7 @@ fn edit<R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(
|
|||
|
||||
issue.transaction("Edit", signer, |tx| {
|
||||
tx.edit(edited.title)?;
|
||||
tx.edit_comment(desc_id, description)?;
|
||||
tx.edit_comment(desc_id, description, vec![])?;
|
||||
tx.label(edited.labels)?;
|
||||
tx.assign(edited.assignees)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -794,6 +794,7 @@ fn test_cob_replication() {
|
|||
"I don't know what it is",
|
||||
&[],
|
||||
&[],
|
||||
[],
|
||||
&bob.signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -843,6 +844,7 @@ fn test_cob_deletion() {
|
|||
"I don't know what it is",
|
||||
&[],
|
||||
&[],
|
||||
[],
|
||||
&alice.signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ keywords = ["radicle", "cob", "cobs"]
|
|||
fastrand = { version = "1.9.0" }
|
||||
log = { version = "0.4.17" }
|
||||
nonempty = { version = "0.8.1", features = ["serialize"] }
|
||||
once_cell = { version = "1.13" }
|
||||
radicle-git-ext = { version = "0.6.0", features = ["serde"] }
|
||||
serde_json = { version = "1.0" }
|
||||
thiserror = { version = "1.0" }
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@
|
|||
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use git_ext::author::Author;
|
||||
use git_ext::commit::{headers::Headers, Commit};
|
||||
use git_ext::Oid;
|
||||
use nonempty::NonEmpty;
|
||||
use once_cell::sync::Lazy;
|
||||
use radicle_git_ext::commit::trailers::OwnedTrailer;
|
||||
|
||||
use crate::change::store::Version;
|
||||
|
|
@ -16,10 +18,13 @@ use crate::{
|
|||
change::{self, store, Change},
|
||||
history::entry,
|
||||
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 {
|
||||
use std::str::Utf8Error;
|
||||
|
|
@ -102,10 +107,11 @@ impl change::Storage for git2::Repository {
|
|||
type_name,
|
||||
tips,
|
||||
message,
|
||||
embeds,
|
||||
contents,
|
||||
} = spec;
|
||||
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 signature = {
|
||||
let sig = signer.sign(revision.as_bytes());
|
||||
|
|
@ -281,6 +287,8 @@ where
|
|||
|
||||
#[cfg(debug_assertions)]
|
||||
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 author = Author {
|
||||
time: git_ext::author::Time::new(timestamp, 0),
|
||||
|
|
@ -308,24 +316,47 @@ where
|
|||
fn write_manifest(
|
||||
repo: &git2::Repository,
|
||||
manifest: &store::Manifest,
|
||||
embeds: Vec<Embed>,
|
||||
contents: &NonEmpty<Vec<u8>>,
|
||||
) -> Result<git2::Oid, git2::Error> {
|
||||
let mut tb = repo.treebuilder(None)?;
|
||||
// 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
|
||||
let serialized_manifest = serde_json::to_vec(manifest).unwrap();
|
||||
let manifest_oid = repo.blob(&serialized_manifest)?;
|
||||
tb.insert(
|
||||
MANIFEST_BLOB_NAME,
|
||||
manifest_oid,
|
||||
git2::FileMode::Blob.into(),
|
||||
)?;
|
||||
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
|
||||
// errors here is a programming error, which we can't recover from.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let manifest = serde_json::to_vec(manifest).unwrap();
|
||||
let manifest_oid = repo.blob(&manifest)?;
|
||||
|
||||
root.insert(
|
||||
MANIFEST_BLOB_NAME,
|
||||
manifest_oid,
|
||||
git2::FileMode::Blob.into(),
|
||||
)?;
|
||||
}
|
||||
|
||||
// Insert each COB entry.
|
||||
for (ix, op) in contents.iter().enumerate() {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ pub struct Template<Id> {
|
|||
pub type_name: TypeName,
|
||||
pub tips: Vec<Id>,
|
||||
pub message: String,
|
||||
pub embeds: Vec<Embed>,
|
||||
pub contents: NonEmpty<Vec<u8>>,
|
||||
}
|
||||
|
||||
|
|
@ -179,3 +180,38 @@ impl Version {
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright © 2021 The Radicle Link Contributors
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::ops::ControlFlow;
|
||||
use std::{collections::BTreeSet, convert::TryInto};
|
||||
|
||||
use git_ext::Oid;
|
||||
use radicle_dag::Dag;
|
||||
|
|
@ -135,8 +135,8 @@ impl ChangeGraph {
|
|||
self.graph.tips().map(|(_, change)| *change.id()).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn number_of_nodes(&self) -> u64 {
|
||||
self.graph.len().try_into().unwrap()
|
||||
pub(crate) fn number_of_nodes(&self) -> usize {
|
||||
self.graph.len()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Copyright © 2021 The Radicle Link Contributors
|
||||
|
||||
#![warn(clippy::unwrap_used)]
|
||||
//! # Collaborative Objects
|
||||
//!
|
||||
//! Collaborative objects are graphs of CRDTs. The current CRDTs that
|
||||
|
|
@ -80,7 +81,7 @@ mod change_graph;
|
|||
mod trailers;
|
||||
|
||||
pub mod change;
|
||||
pub use change::store::{Manifest, Version};
|
||||
pub use change::store::{Embed, Manifest, Version};
|
||||
pub use change::Change;
|
||||
|
||||
pub mod history;
|
||||
|
|
@ -98,9 +99,11 @@ pub use object::{
|
|||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod test;
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests;
|
||||
|
||||
/// The `Store` is an aggregation of the different types of storage
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use nonempty::NonEmpty;
|
||||
|
||||
use crate::Embed;
|
||||
use crate::Store;
|
||||
|
||||
use super::*;
|
||||
|
|
@ -14,17 +15,20 @@ pub struct Create {
|
|||
pub type_name: TypeName,
|
||||
/// The message to add when creating this object.
|
||||
pub message: String,
|
||||
/// Embedded content.
|
||||
pub embeds: Vec<Embed>,
|
||||
/// COB version.
|
||||
pub version: Version,
|
||||
}
|
||||
|
||||
impl Create {
|
||||
fn template(&self) -> change::Template<git_ext::Oid> {
|
||||
fn template(self) -> change::Template<git_ext::Oid> {
|
||||
change::Template {
|
||||
type_name: self.type_name.clone(),
|
||||
type_name: self.type_name,
|
||||
tips: Vec::new(),
|
||||
message: self.message.clone(),
|
||||
contents: self.contents.clone(),
|
||||
message: self.message,
|
||||
embeds: self.embeds,
|
||||
contents: self.contents,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -59,14 +63,15 @@ where
|
|||
S: Store<I>,
|
||||
G: crypto::Signer,
|
||||
{
|
||||
let Create { type_name, .. } = &args;
|
||||
let type_name = args.type_name.clone();
|
||||
let version = args.version;
|
||||
let init_change = storage
|
||||
.store(resource, parents, signer, args.template())
|
||||
.map_err(error::Create::from)?;
|
||||
let object_id = init_change.id().into();
|
||||
|
||||
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) })?;
|
||||
|
||||
let history = History::new_from_root(
|
||||
|
|
@ -79,7 +84,7 @@ where
|
|||
);
|
||||
|
||||
Ok(CollaborativeObject {
|
||||
manifest: Manifest::new(args.type_name, args.version),
|
||||
manifest: Manifest::new(type_name, version),
|
||||
history,
|
||||
id: object_id,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ pub struct ChangeGraphInfo {
|
|||
/// The ID of the object
|
||||
pub object_id: ObjectId,
|
||||
/// 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
|
||||
/// references to the object
|
||||
pub tips: BTreeSet<Oid>,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ use git_ext::Oid;
|
|||
use nonempty::NonEmpty;
|
||||
|
||||
use crate::{
|
||||
change, change_graph::ChangeGraph, history::EntryId, CollaborativeObject, ObjectId, Store,
|
||||
TypeName,
|
||||
change, change_graph::ChangeGraph, history::EntryId, CollaborativeObject, Embed, ObjectId,
|
||||
Store, TypeName,
|
||||
};
|
||||
|
||||
use super::error;
|
||||
|
|
@ -31,6 +31,8 @@ pub struct Update {
|
|||
pub type_name: TypeName,
|
||||
/// The message to add when updating this object.
|
||||
pub message: String,
|
||||
/// Embedded files.
|
||||
pub embeds: Vec<Embed>,
|
||||
}
|
||||
|
||||
/// Update an existing [`CollaborativeObject`].
|
||||
|
|
@ -67,6 +69,7 @@ where
|
|||
let Update {
|
||||
type_name: ref typename,
|
||||
object_id,
|
||||
embeds,
|
||||
changes,
|
||||
message,
|
||||
} = args;
|
||||
|
|
@ -85,6 +88,7 @@ where
|
|||
signer,
|
||||
change::Template {
|
||||
tips: object.tips().iter().cloned().collect(),
|
||||
embeds,
|
||||
contents: changes,
|
||||
type_name: typename.clone(),
|
||||
message,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ fn roundtrip() {
|
|||
contents: nonempty!(Vec::new()),
|
||||
type_name: typename.clone(),
|
||||
message: "creating xyz.rad.issue".to_string(),
|
||||
embeds: vec![],
|
||||
version: Version::default(),
|
||||
},
|
||||
)
|
||||
|
|
@ -67,6 +68,7 @@ fn list_cobs() {
|
|||
contents: nonempty!(b"issue 1".to_vec()),
|
||||
type_name: typename.clone(),
|
||||
message: "creating xyz.rad.issue".to_string(),
|
||||
embeds: vec![],
|
||||
version: Version::default(),
|
||||
},
|
||||
)
|
||||
|
|
@ -82,6 +84,7 @@ fn list_cobs() {
|
|||
contents: nonempty!(b"issue 2".to_vec()),
|
||||
type_name: typename.clone(),
|
||||
message: "commenting xyz.rad.issue".to_string(),
|
||||
embeds: vec![],
|
||||
version: Version::default(),
|
||||
},
|
||||
)
|
||||
|
|
@ -117,6 +120,7 @@ fn update_cob() {
|
|||
contents: nonempty!(Vec::new()),
|
||||
type_name: typename.clone(),
|
||||
message: "creating xyz.rad.issue".to_string(),
|
||||
embeds: vec![],
|
||||
version: Version::default(),
|
||||
},
|
||||
)
|
||||
|
|
@ -136,6 +140,7 @@ fn update_cob() {
|
|||
changes: nonempty!(b"issue 1".to_vec()),
|
||||
object_id: *cob.id(),
|
||||
type_name: typename.clone(),
|
||||
embeds: vec![],
|
||||
message: "commenting xyz.rad.issue".to_string(),
|
||||
},
|
||||
)
|
||||
|
|
@ -176,6 +181,7 @@ fn traverse_cobs() {
|
|||
contents: nonempty!(b"issue 1".to_vec()),
|
||||
type_name: typename.clone(),
|
||||
message: "creating xyz.rad.issue".to_string(),
|
||||
embeds: vec![],
|
||||
version: Version::default(),
|
||||
},
|
||||
)
|
||||
|
|
@ -199,6 +205,7 @@ fn traverse_cobs() {
|
|||
changes: nonempty!(b"issue 2".to_vec()),
|
||||
object_id: *cob.id(),
|
||||
type_name: typename,
|
||||
embeds: vec![],
|
||||
message: "commenting on xyz.rad.issue".to_string(),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ impl From<git2::Oid> for ResourceCommitTrailer {
|
|||
impl From<ResourceCommitTrailer> for Trailer<'_> {
|
||||
fn from(containing: ResourceCommitTrailer) -> Self {
|
||||
Trailer {
|
||||
// SAFETY: "Rad-Resource" is a valid `Token`.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
token: Token::try_from("Rad-Resource").unwrap(),
|
||||
value: containing.0.to_string().into(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -507,6 +507,7 @@ async fn issue_create_handler(
|
|||
issue.description,
|
||||
&issue.labels,
|
||||
&issue.assignees,
|
||||
[],
|
||||
&signer,
|
||||
)
|
||||
.map_err(Error::from)?;
|
||||
|
|
@ -546,9 +547,9 @@ async fn issue_update_handler(
|
|||
issue::Action::Edit { title } => {
|
||||
issue.edit(title, &signer)?;
|
||||
}
|
||||
issue::Action::Comment { body, reply_to } => {
|
||||
issue::Action::Comment { body, reply_to, .. } => {
|
||||
if let Some(to) = reply_to {
|
||||
issue.comment(body, to, &signer)?;
|
||||
issue.comment(body, to, [], &signer)?;
|
||||
} else {
|
||||
return Err(Error::BadRequest("`replyTo` missing".to_owned()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
&[],
|
||||
&[],
|
||||
[],
|
||||
signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
|
|||
|
|
@ -315,7 +315,7 @@ impl<G: Signer + cyphernet::Ecdh> NodeHandle<G> {
|
|||
let repo = self.storage.repository(rid).unwrap();
|
||||
let mut issues = issue::Issues::open(&repo).unwrap();
|
||||
*issues
|
||||
.create(title, desc, &[], &[], &self.signer)
|
||||
.create(title, desc, &[], &[], [], &self.signer)
|
||||
.unwrap()
|
||||
.id()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ pub mod test;
|
|||
|
||||
pub use cob::{
|
||||
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 common::*;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use std::str::FromStr;
|
|||
use localtime::LocalTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::git_ext::Oid;
|
||||
use crate::prelude::*;
|
||||
|
||||
/// 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)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -320,7 +320,7 @@ impl store::FromHistory for Proposal {
|
|||
type Error = ApplyError;
|
||||
|
||||
fn type_name() -> &'static TypeName {
|
||||
&*TYPENAME
|
||||
&TYPENAME
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), Self::Error> {
|
||||
|
|
@ -390,6 +390,7 @@ impl store::FromHistory for Proposal {
|
|||
body,
|
||||
reply_to,
|
||||
None,
|
||||
vec![],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
|
@ -399,7 +400,14 @@ impl store::FromHistory for Proposal {
|
|||
body,
|
||||
} => {
|
||||
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 } => {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ use serde::{Deserialize, Serialize};
|
|||
use thiserror::Error;
|
||||
|
||||
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::{FromHistory as _, HistoryAction};
|
||||
use crate::cob::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::prelude::{Did, ReadRepository};
|
||||
use crate::storage::WriteRepository;
|
||||
|
|
@ -109,7 +109,7 @@ impl store::FromHistory for Issue {
|
|||
type Error = Error;
|
||||
|
||||
fn type_name() -> &'static TypeName {
|
||||
&*TYPENAME
|
||||
&TYPENAME
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), Self::Error> {
|
||||
|
|
@ -154,7 +154,11 @@ impl store::FromHistory for Issue {
|
|||
Action::Label { labels } => {
|
||||
self.labels = BTreeSet::from_iter(labels);
|
||||
}
|
||||
Action::Comment { body, reply_to } => {
|
||||
Action::Comment {
|
||||
body,
|
||||
reply_to,
|
||||
embeds,
|
||||
} => {
|
||||
thread::comment(
|
||||
&mut self.thread,
|
||||
op.id,
|
||||
|
|
@ -163,10 +167,11 @@ impl store::FromHistory for Issue {
|
|||
body,
|
||||
reply_to,
|
||||
None,
|
||||
embeds,
|
||||
)?;
|
||||
}
|
||||
Action::CommentEdit { id, body } => {
|
||||
thread::edit(&mut self.thread, op.id, id, op.timestamp, body)?;
|
||||
Action::CommentEdit { id, body, embeds } => {
|
||||
thread::edit(&mut self.thread, op.id, id, op.timestamp, body, embeds)?;
|
||||
}
|
||||
Action::CommentRedact { id } => {
|
||||
thread::redact(&mut self.thread, op.id, id)?;
|
||||
|
|
@ -252,10 +257,19 @@ impl store::Transaction<Issue> {
|
|||
}
|
||||
|
||||
/// 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 {
|
||||
id,
|
||||
body: body.to_string(),
|
||||
embeds: hashed,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -276,10 +290,15 @@ impl store::Transaction<Issue> {
|
|||
&mut self,
|
||||
body: S,
|
||||
reply_to: CommentId,
|
||||
embeds: Vec<Embed>,
|
||||
) -> Result<(), store::Error> {
|
||||
let hashed = embeds.iter().map(|e| e.hashed()).collect();
|
||||
|
||||
self.embed(embeds)?;
|
||||
self.push(Action::Comment {
|
||||
body: body.to_string(),
|
||||
reply_to: Some(reply_to),
|
||||
embeds: hashed,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -307,10 +326,19 @@ impl store::Transaction<Issue> {
|
|||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/// 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 {
|
||||
body: body.to_string(),
|
||||
reply_to: None,
|
||||
embeds: hashed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -367,6 +395,7 @@ where
|
|||
pub fn edit_description<G: Signer>(
|
||||
&mut self,
|
||||
description: impl ToString,
|
||||
embeds: impl IntoIterator<Item = Embed>,
|
||||
signer: &G,
|
||||
) -> Result<EntryId, Error> {
|
||||
let Some((id, _)) = self.thread.comments().next() else {
|
||||
|
|
@ -374,7 +403,7 @@ where
|
|||
};
|
||||
let id = *id;
|
||||
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,
|
||||
body: S,
|
||||
reply_to: CommentId,
|
||||
embeds: impl IntoIterator<Item = Embed>,
|
||||
signer: &G,
|
||||
) -> Result<EntryId, Error> {
|
||||
assert!(
|
||||
self.thread.comment(&reply_to).is_some(),
|
||||
"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.
|
||||
|
|
@ -502,10 +534,11 @@ where
|
|||
description: impl ToString,
|
||||
labels: &[Label],
|
||||
assignees: &[Did],
|
||||
embeds: impl IntoIterator<Item = Embed>,
|
||||
signer: &G,
|
||||
) -> Result<IssueMut<'a, 'g, R>, Error> {
|
||||
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.edit(title)?;
|
||||
tx.label(labels.to_owned())?;
|
||||
|
|
@ -573,11 +606,21 @@ pub enum Action {
|
|||
/// Should be the root [`CommentId`] if it's a top-level comment.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reply_to: Option<CommentId>,
|
||||
/// Embeded content.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
embeds: Vec<Embed<Uri>>,
|
||||
},
|
||||
|
||||
/// Edit a comment.
|
||||
#[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.
|
||||
#[serde(rename = "comment.redact")]
|
||||
|
|
@ -600,6 +643,7 @@ mod test {
|
|||
|
||||
use super::*;
|
||||
use crate::cob::{ActorId, Reaction};
|
||||
use crate::git::Oid;
|
||||
use crate::test;
|
||||
use crate::test::arbitrary;
|
||||
|
||||
|
|
@ -611,7 +655,14 @@ mod test {
|
|||
let mut eve_issues = Issues::open(&*t.eve.repo).unwrap();
|
||||
|
||||
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();
|
||||
let id = *issue_alice.id();
|
||||
|
||||
|
|
@ -622,10 +673,10 @@ mod test {
|
|||
let mut issue_bob = bob_issues.get_mut(&id).unwrap();
|
||||
|
||||
issue_bob
|
||||
.comment("Bob's reply", id.into(), &t.bob.signer)
|
||||
.comment("Bob's reply", id.into(), vec![], &t.bob.signer)
|
||||
.unwrap();
|
||||
issue_alice
|
||||
.comment("Alice's reply", id.into(), &t.alice.signer)
|
||||
.comment("Alice's reply", id.into(), vec![], &t.alice.signer)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(issue_bob.comments().count(), 2);
|
||||
|
|
@ -653,7 +704,7 @@ mod test {
|
|||
t.eve.repo.fetch(&t.alice);
|
||||
|
||||
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();
|
||||
|
||||
t.bob.repo.fetch(&t.eve);
|
||||
|
|
@ -698,6 +749,7 @@ mod test {
|
|||
"Blah blah blah.",
|
||||
&[],
|
||||
&[assignee],
|
||||
[],
|
||||
&node.signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -736,6 +788,7 @@ mod test {
|
|||
"Blah blah blah.",
|
||||
&[],
|
||||
&[assignee],
|
||||
[],
|
||||
&node.signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -760,7 +813,14 @@ mod test {
|
|||
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||
let mut issues = Issues::open(&*repo).unwrap();
|
||||
let created = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
||||
.create(
|
||||
"My first issue",
|
||||
"Blah blah blah.",
|
||||
&[],
|
||||
&[],
|
||||
[],
|
||||
&node.signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (id, created) = (created.id, created.issue);
|
||||
|
|
@ -779,7 +839,14 @@ mod test {
|
|||
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||
let mut issues = Issues::open(&*repo).unwrap();
|
||||
let mut issue = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
||||
.create(
|
||||
"My first issue",
|
||||
"Blah blah blah.",
|
||||
&[],
|
||||
&[],
|
||||
[],
|
||||
&node.signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
issue
|
||||
|
|
@ -820,6 +887,7 @@ mod test {
|
|||
"Blah blah blah.",
|
||||
&[],
|
||||
&[assignee, assignee_two],
|
||||
[],
|
||||
&node.signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -839,7 +907,14 @@ mod test {
|
|||
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||
let mut issues = Issues::open(&*repo).unwrap();
|
||||
let mut issue = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
||||
.create(
|
||||
"My first issue",
|
||||
"Blah blah blah.",
|
||||
&[],
|
||||
&[],
|
||||
[],
|
||||
&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 mut issues = Issues::open(&*repo).unwrap();
|
||||
let mut issue = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
||||
.create(
|
||||
"My first issue",
|
||||
"Blah blah blah.",
|
||||
&[],
|
||||
&[],
|
||||
[],
|
||||
&node.signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
issue
|
||||
.edit_description("Bob Loblaw law blog", &node.signer)
|
||||
.edit_description("Bob Loblaw law blog", vec![], &node.signer)
|
||||
.unwrap();
|
||||
|
||||
let id = issue.id;
|
||||
|
|
@ -875,7 +957,14 @@ mod test {
|
|||
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||
let mut issues = Issues::open(&*repo).unwrap();
|
||||
let mut issue = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
||||
.create(
|
||||
"My first issue",
|
||||
"Blah blah blah.",
|
||||
&[],
|
||||
&[],
|
||||
[],
|
||||
&node.signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (comment, _) = issue.root();
|
||||
|
|
@ -897,13 +986,24 @@ mod test {
|
|||
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||
let mut issues = Issues::open(&*repo).unwrap();
|
||||
let mut issue = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
||||
.create(
|
||||
"My first issue",
|
||||
"Blah blah blah.",
|
||||
&[],
|
||||
&[],
|
||||
[],
|
||||
&node.signer,
|
||||
)
|
||||
.unwrap();
|
||||
let (root, _) = issue.root();
|
||||
let root = *root;
|
||||
|
||||
let c1 = issue.comment("Hi hi hi.", root, &node.signer).unwrap();
|
||||
let c2 = issue.comment("Ha ha ha.", root, &node.signer).unwrap();
|
||||
let c1 = issue
|
||||
.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 mut issue = issues.get_mut(&id).unwrap();
|
||||
|
|
@ -913,10 +1013,14 @@ mod test {
|
|||
assert_eq!(reply1.body(), "Hi hi hi.");
|
||||
assert_eq!(reply2.body(), "Ha ha ha.");
|
||||
|
||||
issue.comment("Re: Hi.", c1, &node.signer).unwrap();
|
||||
issue.comment("Re: Ha.", c2, &node.signer).unwrap();
|
||||
issue.comment("Re: Ha. Ha.", c2, &node.signer).unwrap();
|
||||
issue.comment("Re: Ha. Ha. Ha.", c2, &node.signer).unwrap();
|
||||
issue.comment("Re: Hi.", c1, vec![], &node.signer).unwrap();
|
||||
issue.comment("Re: Ha.", c2, vec![], &node.signer).unwrap();
|
||||
issue
|
||||
.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();
|
||||
|
||||
|
|
@ -942,6 +1046,7 @@ mod test {
|
|||
"Blah blah blah.",
|
||||
&[ux_label.clone()],
|
||||
&[],
|
||||
[],
|
||||
&node.signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -971,15 +1076,26 @@ mod test {
|
|||
let author = *node.signer.public_key();
|
||||
let mut issues = Issues::open(&*repo).unwrap();
|
||||
let mut issue = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
||||
.create(
|
||||
"My first issue",
|
||||
"Blah blah blah.",
|
||||
&[],
|
||||
&[],
|
||||
[],
|
||||
&node.signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// The root thread op id is always the same.
|
||||
let (c0, _) = issue.root();
|
||||
let c0 = *c0;
|
||||
|
||||
issue.comment("Ho ho ho.", c0, &node.signer).unwrap();
|
||||
issue.comment("Ha ha ha.", c0, &node.signer).unwrap();
|
||||
issue
|
||||
.comment("Ho ho ho.", c0, vec![], &node.signer)
|
||||
.unwrap();
|
||||
issue
|
||||
.comment("Ha ha ha.", c0, vec![], &node.signer)
|
||||
.unwrap();
|
||||
|
||||
let id = issue.id;
|
||||
let issue = issues.get(&id).unwrap().unwrap();
|
||||
|
|
@ -1017,13 +1133,13 @@ mod test {
|
|||
let mut issues = Issues::open(&*repo).unwrap();
|
||||
|
||||
issues
|
||||
.create("First", "Blah", &[], &[], &node.signer)
|
||||
.create("First", "Blah", &[], &[], [], &node.signer)
|
||||
.unwrap();
|
||||
issues
|
||||
.create("Second", "Blah", &[], &[], &node.signer)
|
||||
.create("Second", "Blah", &[], &[], [], &node.signer)
|
||||
.unwrap();
|
||||
issues
|
||||
.create("Third", "Blah", &[], &[], &node.signer)
|
||||
.create("Third", "Blah", &[], &[], [], &node.signer)
|
||||
.unwrap();
|
||||
|
||||
let issues = issues
|
||||
|
|
@ -1050,6 +1166,7 @@ mod test {
|
|||
"Blah blah blah.\nYah yah yah",
|
||||
&[],
|
||||
&[],
|
||||
[],
|
||||
&node.signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -1064,4 +1181,106 @@ mod test {
|
|||
assert_eq!(issue.comments().count(), 1);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ impl store::FromHistory for Issue {
|
|||
type Error = Error;
|
||||
|
||||
fn type_name() -> &'static TypeName {
|
||||
&*issue::TYPENAME
|
||||
&issue::TYPENAME
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), Self::Error> {
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ impl store::FromHistory for Patch {
|
|||
type Error = Error;
|
||||
|
||||
fn type_name() -> &'static TypeName {
|
||||
&*TYPENAME
|
||||
&TYPENAME
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), Self::Error> {
|
||||
|
|
@ -242,6 +242,7 @@ impl store::FromHistory for Patch {
|
|||
comment,
|
||||
timestamp,
|
||||
body,
|
||||
vec![],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
|
@ -277,6 +278,7 @@ impl store::FromHistory for Patch {
|
|||
body,
|
||||
None,
|
||||
Some(location),
|
||||
vec![],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -449,7 +449,7 @@ impl store::FromHistory for Patch {
|
|||
type Error = Error;
|
||||
|
||||
fn type_name() -> &'static TypeName {
|
||||
&*TYPENAME
|
||||
&TYPENAME
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), Self::Error> {
|
||||
|
|
@ -626,7 +626,14 @@ impl store::FromHistory for Patch {
|
|||
body,
|
||||
} => {
|
||||
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 { .. } => {
|
||||
|
|
@ -650,6 +657,7 @@ impl store::FromHistory for Patch {
|
|||
body,
|
||||
reply_to,
|
||||
location,
|
||||
vec![],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
|
@ -739,6 +747,7 @@ impl store::FromHistory for Patch {
|
|||
body,
|
||||
reply_to,
|
||||
None,
|
||||
vec![],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
|
@ -748,7 +757,14 @@ impl store::FromHistory for Patch {
|
|||
body,
|
||||
} => {
|
||||
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 } => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::cob::common::Timestamp;
|
||||
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::prelude::*;
|
||||
use crate::storage::git as storage;
|
||||
|
|
@ -165,6 +167,7 @@ where
|
|||
object_id: ObjectId,
|
||||
message: &str,
|
||||
actions: impl Into<NonEmpty<T::Action>>,
|
||||
embeds: Vec<Embed>,
|
||||
signer: &G,
|
||||
) -> Result<Updated, Error> {
|
||||
let actions = actions.into();
|
||||
|
|
@ -180,6 +183,7 @@ where
|
|||
object_id,
|
||||
type_name: T::type_name().clone(),
|
||||
message: message.to_owned(),
|
||||
embeds,
|
||||
changes,
|
||||
},
|
||||
)?;
|
||||
|
|
@ -194,6 +198,7 @@ where
|
|||
&self,
|
||||
message: &str,
|
||||
actions: impl Into<NonEmpty<T::Action>>,
|
||||
embeds: Vec<Embed>,
|
||||
signer: &G,
|
||||
) -> Result<(ObjectId, T), Error> {
|
||||
let actions = actions.into();
|
||||
|
|
@ -209,6 +214,7 @@ where
|
|||
type_name: T::type_name().clone(),
|
||||
version: Version::default(),
|
||||
message: message.to_owned(),
|
||||
embeds,
|
||||
contents,
|
||||
},
|
||||
)?;
|
||||
|
|
@ -288,6 +294,7 @@ where
|
|||
pub struct Transaction<T: FromHistory> {
|
||||
actor: ActorId,
|
||||
actions: Vec<T::Action>,
|
||||
embeds: Vec<Embed>,
|
||||
}
|
||||
|
||||
impl<T: FromHistory> Transaction<T> {
|
||||
|
|
@ -296,6 +303,7 @@ impl<T: FromHistory> Transaction<T> {
|
|||
Self {
|
||||
actor,
|
||||
actions: Vec::new(),
|
||||
embeds: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -313,15 +321,13 @@ impl<T: FromHistory> Transaction<T> {
|
|||
T::Action: Serialize + Clone,
|
||||
{
|
||||
let actor = *signer.public_key();
|
||||
let mut tx = Transaction {
|
||||
actor,
|
||||
actions: Vec::new(),
|
||||
};
|
||||
let mut tx = Transaction::new(actor);
|
||||
|
||||
operations(&mut tx)?;
|
||||
|
||||
let actions = NonEmpty::from_vec(tx.actions)
|
||||
.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))
|
||||
}
|
||||
|
|
@ -333,6 +339,13 @@ impl<T: FromHistory> Transaction<T> {
|
|||
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.
|
||||
///
|
||||
/// Returns an operation that can be applied onto an in-memory state.
|
||||
|
|
@ -353,7 +366,7 @@ impl<T: FromHistory> Transaction<T> {
|
|||
head,
|
||||
object,
|
||||
parents,
|
||||
} = store.update(id, msg, actions.clone(), signer)?;
|
||||
} = store.update(id, msg, actions.clone(), self.embeds, signer)?;
|
||||
let id = EntryId::from(head);
|
||||
let author = self.actor;
|
||||
let timestamp = Timestamp::from_secs(object.history().timestamp());
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ use serde::{ser::SerializeStruct, Deserialize, Serialize};
|
|||
use thiserror::Error;
|
||||
|
||||
use crate::cob;
|
||||
use crate::cob::common::{Reaction, Timestamp};
|
||||
use crate::cob::{ActorId, EntryId, Op};
|
||||
use crate::cob::common::{Reaction, Timestamp, Uri};
|
||||
use crate::cob::{ActorId, Embed, EntryId, Op};
|
||||
use crate::prelude::ReadRepository;
|
||||
|
||||
/// Type name of a thread, as well as the domain for all thread operations.
|
||||
|
|
@ -49,6 +49,8 @@ pub struct Edit {
|
|||
pub timestamp: Timestamp,
|
||||
/// Edit contents. Replaces previous edits.
|
||||
pub body: String,
|
||||
/// Edit embed list.
|
||||
pub embeds: Vec<Embed<Uri>>,
|
||||
}
|
||||
|
||||
/// A comment on a discussion thread.
|
||||
|
|
@ -72,7 +74,7 @@ impl<T: Serialize> Serialize for Comment<T> {
|
|||
where
|
||||
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())?;
|
||||
if let Some(loc) = &self.location {
|
||||
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("body", self.body())?;
|
||||
state.serialize_field("embeds", self.embeds())?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
|
@ -93,9 +96,14 @@ impl<L> Comment<L> {
|
|||
body: String,
|
||||
reply_to: Option<CommentId>,
|
||||
location: Option<L>,
|
||||
embeds: Vec<Embed<Uri>>,
|
||||
timestamp: Timestamp,
|
||||
) -> Self {
|
||||
let edit = Edit { body, timestamp };
|
||||
let edit = Edit {
|
||||
body,
|
||||
embeds,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
Self {
|
||||
author,
|
||||
|
|
@ -139,8 +147,12 @@ impl<L> Comment<L> {
|
|||
}
|
||||
|
||||
/// Add an edit.
|
||||
pub fn edit(&mut self, body: String, timestamp: Timestamp) {
|
||||
self.edits.push(Edit { body, timestamp });
|
||||
pub fn edit(&mut self, body: String, embeds: Vec<Embed<Uri>>, timestamp: Timestamp) {
|
||||
self.edits.push(Edit {
|
||||
body,
|
||||
embeds,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
/// Comment reactions.
|
||||
|
|
@ -152,6 +164,14 @@ impl<L> Comment<L> {
|
|||
pub fn location(&self) -> Option<&L> {
|
||||
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> {
|
||||
|
|
@ -283,7 +303,7 @@ impl cob::store::FromHistory for Thread {
|
|||
type Error = Error;
|
||||
|
||||
fn type_name() -> &'static radicle_cob::TypeName {
|
||||
&*TYPENAME
|
||||
&TYPENAME
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), Self::Error> {
|
||||
|
|
@ -301,10 +321,10 @@ impl cob::store::FromHistory for Thread {
|
|||
for action in op.actions {
|
||||
match action {
|
||||
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 } => {
|
||||
edit(self, op.id, id, timestamp, body)?;
|
||||
edit(self, op.id, id, timestamp, body, vec![])?;
|
||||
}
|
||||
Action::Redact { id } => {
|
||||
redact(self, op.id, id)?;
|
||||
|
|
@ -330,6 +350,7 @@ pub fn comment<L>(
|
|||
body: String,
|
||||
reply_to: Option<CommentId>,
|
||||
location: Option<L>,
|
||||
embeds: Vec<Embed<Uri>>,
|
||||
) -> Result<(), Error> {
|
||||
if body.is_empty() {
|
||||
return Err(Error::Comment(id));
|
||||
|
|
@ -341,7 +362,9 @@ pub fn comment<L>(
|
|||
// underlying store guarantees exactly-once delivery of ops.
|
||||
thread.comments.insert(
|
||||
id,
|
||||
Some(Comment::new(author, body, reply_to, location, timestamp)),
|
||||
Some(Comment::new(
|
||||
author, body, reply_to, location, embeds, timestamp,
|
||||
)),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
|
@ -353,6 +376,7 @@ pub fn edit<L>(
|
|||
comment: EntryId,
|
||||
timestamp: Timestamp,
|
||||
body: String,
|
||||
embeds: Vec<Embed<Uri>>,
|
||||
) -> Result<(), Error> {
|
||||
if body.is_empty() {
|
||||
return Err(Error::Edit(id));
|
||||
|
|
@ -367,7 +391,7 @@ pub fn edit<L>(
|
|||
// that as an error.
|
||||
if let Some(comment) = thread.comments.get_mut(&comment) {
|
||||
if let Some(comment) = comment {
|
||||
comment.edit(body, timestamp);
|
||||
comment.edit(body, embeds, timestamp);
|
||||
}
|
||||
} else {
|
||||
return Err(Error::Missing(comment));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#![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)]
|
||||
|
||||
pub extern crate radicle_crypto as crypto;
|
||||
|
|
|
|||
|
|
@ -333,6 +333,9 @@ pub trait ReadRepository: Sized {
|
|||
fn blob_at<'a>(&'a self, commit: Oid, path: &'a Path)
|
||||
-> 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`].
|
||||
fn validate(&self) -> Result<(), VerifyError> {
|
||||
for (_, remote) in self.remotes()? {
|
||||
|
|
|
|||
|
|
@ -357,14 +357,18 @@ impl ReadRepository for Repository {
|
|||
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 {
|
||||
object: oid.into(),
|
||||
object: commit.into(),
|
||||
path,
|
||||
}
|
||||
.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> {
|
||||
// Contains a copy of the signed refs of this remote.
|
||||
let mut signed = BTreeMap::from((*remote.refs).clone());
|
||||
|
|
|
|||
|
|
@ -283,6 +283,10 @@ impl<'a> ReadRepository for DraftStore<'a> {
|
|||
self.repo.blob_at(oid, path)
|
||||
}
|
||||
|
||||
fn blob(&self, oid: git_ext::Oid) -> Result<raw::Blob, ext::Error> {
|
||||
self.repo.blob(oid)
|
||||
}
|
||||
|
||||
fn reference(
|
||||
&self,
|
||||
remote: &RemoteId,
|
||||
|
|
|
|||
|
|
@ -178,6 +178,10 @@ impl ReadRepository for MockRepository {
|
|||
Ok(true)
|
||||
}
|
||||
|
||||
fn blob(&self, _oid: Oid) -> Result<git2::Blob, git_ext::Error> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn blob_at<'a>(
|
||||
&'a self,
|
||||
_oid: git_ext::Oid,
|
||||
|
|
|
|||
Loading…
Reference in New Issue