1
0
Fork 0
mirror of https://github.com/Nutomic/ibis.git synced 2025-01-27 19:15:53 +00:00

Show pending edits in history (fixes #109)

This commit is contained in:
Felix Ableitner 2025-01-17 10:58:37 +01:00
parent b573c92a19
commit 0edc863bb7
15 changed files with 93 additions and 47 deletions

View file

@ -0,0 +1 @@
alter table edit drop column pending;

View file

@ -0,0 +1 @@
alter table edit add column pending bool not null default false;

View file

@ -237,6 +237,7 @@ pub(in crate::backend::api) async fn fork_article(
hash: e.hash, hash: e.hash,
previous_version_id: e.previous_version_id, previous_version_id: e.previous_version_id,
published: Utc::now(), published: Utc::now(),
pending: false,
}; };
DbEdit::create(&form, &data)?; DbEdit::create(&form, &data)?;
} }

View file

@ -92,6 +92,7 @@ pub(in crate::backend::api) async fn site_view(
#[debug_handler] #[debug_handler]
pub async fn edit_list( pub async fn edit_list(
Query(query): Query<GetEditList>, Query(query): Query<GetEditList>,
user: Option<Extension<LocalUserView>>,
data: Data<IbisData>, data: Data<IbisData>,
) -> MyResult<Json<Vec<EditView>>> { ) -> MyResult<Json<Vec<EditView>>> {
let params = if let Some(article_id) = query.article_id { let params = if let Some(article_id) = query.article_id {
@ -101,7 +102,7 @@ pub async fn edit_list(
} else { } else {
return Err(anyhow!("Must provide article_id or person_id").into()); return Err(anyhow!("Must provide article_id or person_id").into());
}; };
Ok(Json(DbEdit::view(params, &data)?)) Ok(Json(DbEdit::view(params, &user.map(|u| u.0), &data)?))
} }
/// Trims the string param, and converts to None if it is empty /// Trims the string param, and converts to None if it is empty

View file

@ -1,6 +1,9 @@
use crate::{ use crate::{
backend::{ backend::{
database::{schema::conflict, IbisData}, database::{
schema::{conflict, edit},
IbisData,
},
federation::activities::submit_article_update, federation::activities::submit_article_update,
utils::{error::MyResult, generate_article_version}, utils::{error::MyResult, generate_article_version},
}, },
@ -69,14 +72,21 @@ impl DbConflict {
} }
/// Delete merge conflict which was created by specific user /// Delete merge conflict which was created by specific user
pub fn delete(id: ConflictId, creator_id: PersonId, data: &IbisData) -> MyResult<Self> { pub fn delete(id: ConflictId, creator_id: PersonId, data: &IbisData) -> MyResult<()> {
let mut conn = data.db_pool.get()?; let mut conn = data.db_pool.get()?;
Ok(delete( let conflict: Self = delete(
conflict::table conflict::table
.filter(conflict::dsl::creator_id.eq(creator_id)) .filter(conflict::dsl::creator_id.eq(creator_id))
.find(id), .find(id),
) )
.get_result(conn.deref_mut())?) .get_result(conn.deref_mut())?;
delete(
edit::table
.filter(edit::dsl::creator_id.eq(creator_id))
.filter(edit::dsl::hash.eq(conflict.hash)),
)
.execute(conn.deref_mut())?;
Ok(())
} }
pub async fn to_api_conflict(&self, data: &Data<IbisData>) -> MyResult<Option<ApiConflict>> { pub async fn to_api_conflict(&self, data: &Data<IbisData>) -> MyResult<Option<ApiConflict>> {

View file

@ -7,11 +7,21 @@ use crate::{
common::{ common::{
article::{DbArticle, DbEdit, EditVersion, EditView}, article::{DbArticle, DbEdit, EditVersion, EditView},
newtypes::{ArticleId, PersonId}, newtypes::{ArticleId, PersonId},
user::LocalUserView,
}, },
}; };
use activitypub_federation::fetch::object_id::ObjectId; use activitypub_federation::fetch::object_id::ObjectId;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use diesel::{insert_into, AsChangeset, ExpressionMethods, Insertable, QueryDsl, RunQueryDsl}; use diesel::{
dsl::not,
insert_into,
AsChangeset,
BoolExpressionMethods,
ExpressionMethods,
Insertable,
QueryDsl,
RunQueryDsl,
};
use diffy::create_patch; use diffy::create_patch;
use std::ops::DerefMut; use std::ops::DerefMut;
@ -26,6 +36,7 @@ pub struct DbEditForm {
pub article_id: ArticleId, pub article_id: ArticleId,
pub previous_version_id: EditVersion, pub previous_version_id: EditVersion,
pub published: DateTime<Utc>, pub published: DateTime<Utc>,
pub pending: bool,
} }
impl DbEditForm { impl DbEditForm {
@ -35,6 +46,7 @@ impl DbEditForm {
updated_text: &str, updated_text: &str,
summary: String, summary: String,
previous_version_id: EditVersion, previous_version_id: EditVersion,
pending: bool,
) -> MyResult<Self> { ) -> MyResult<Self> {
let diff = create_patch(&original_article.text, updated_text); let diff = create_patch(&original_article.text, updated_text);
let version = EditVersion::new(&diff.to_string()); let version = EditVersion::new(&diff.to_string());
@ -48,6 +60,7 @@ impl DbEditForm {
previous_version_id, previous_version_id,
summary, summary,
published: Utc::now(), published: Utc::now(),
pending,
}) })
} }
@ -96,11 +109,18 @@ impl DbEdit {
.get_results(conn.deref_mut())?) .get_results(conn.deref_mut())?)
} }
pub fn view(params: ViewEditParams, data: &IbisData) -> MyResult<Vec<EditView>> { pub fn view(
params: ViewEditParams,
user: &Option<LocalUserView>,
data: &IbisData,
) -> MyResult<Vec<EditView>> {
let mut conn = data.db_pool.get()?; let mut conn = data.db_pool.get()?;
let person_id = user.as_ref().map(|u| u.person.id).unwrap_or(PersonId(-1));
let query = edit::table let query = edit::table
.inner_join(article::table) .inner_join(article::table)
.inner_join(person::table) .inner_join(person::table)
// only the creator can view pending edits
.filter(not(edit::pending).or(edit::creator_id.eq(person_id)))
.into_boxed(); .into_boxed();
let query = match params { let query = match params {

View file

@ -40,6 +40,7 @@ diesel::table! {
article_id -> Int4, article_id -> Int4,
previous_version_id -> Uuid, previous_version_id -> Uuid,
published -> Timestamptz, published -> Timestamptz,
pending -> Bool,
} }
} }

View file

@ -10,11 +10,10 @@ use crate::{
common::{ common::{
article::{DbArticle, DbEdit, EditVersion}, article::{DbArticle, DbEdit, EditVersion},
instance::DbInstance, instance::DbInstance,
newtypes::{EditId, PersonId}, newtypes::PersonId,
}, },
}; };
use activitypub_federation::config::Data; use activitypub_federation::config::Data;
use chrono::Utc;
pub mod accept; pub mod accept;
pub mod create_article; pub mod create_article;
@ -31,12 +30,13 @@ pub async fn submit_article_update(
creator_id: PersonId, creator_id: PersonId,
data: &Data<IbisData>, data: &Data<IbisData>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let form = DbEditForm::new( let mut form = DbEditForm::new(
original_article, original_article,
creator_id, creator_id,
&new_text, &new_text,
summary, summary,
previous_version, previous_version,
false,
)?; )?;
if original_article.local { if original_article.local {
let edit = DbEdit::create(&form, data)?; let edit = DbEdit::create(&form, data)?;
@ -44,18 +44,9 @@ pub async fn submit_article_update(
UpdateLocalArticle::send(updated_article, vec![], data).await?; UpdateLocalArticle::send(updated_article, vec![], data).await?;
} else { } else {
// dont insert edit into db, might be invalid in case of conflict // insert edit as pending, so only the creator can see it
let edit = DbEdit { form.pending = true;
id: EditId(-1), let edit = DbEdit::create(&form, data)?;
creator_id,
hash: form.hash,
ap_id: form.ap_id,
diff: form.diff,
summary: form.summary,
article_id: form.article_id,
previous_version_id: form.previous_version_id,
published: Utc::now(),
};
let instance = DbInstance::read(original_article.instance_id, data)?; let instance = DbInstance::read(original_article.instance_id, data)?;
UpdateRemoteArticle::send(edit, instance, data).await?; UpdateRemoteArticle::send(edit, instance, data).await?;
} }

View file

@ -98,6 +98,7 @@ impl Object for DbEdit {
hash: json.version, hash: json.version,
previous_version_id: json.previous_version, previous_version_id: json.previous_version,
published: json.published, published: json.published,
pending: false,
}; };
let edit = DbEdit::create(&form, data)?; let edit = DbEdit::create(&form, data)?;
Ok(edit) Ok(edit)

View file

@ -57,6 +57,18 @@ pub(super) fn generate_article_version(
Err(anyhow!("failed to generate article version").into()) Err(anyhow!("failed to generate article version").into())
} }
/// Use a single static keypair during testing which is signficantly faster than
/// generating dozens of keys from scratch.
pub fn generate_keypair() -> MyResult<Keypair> {
if cfg!(debug_assertions) {
static KEYPAIR: LazyLock<Keypair> =
LazyLock::new(|| generate_actor_keypair().expect("generate keypair"));
Ok(KEYPAIR.clone())
} else {
Ok(generate_actor_keypair()?)
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
@ -81,6 +93,7 @@ mod test {
article_id: ArticleId(0), article_id: ArticleId(0),
previous_version_id: Default::default(), previous_version_id: Default::default(),
published: Utc::now(), published: Utc::now(),
pending: false,
}) })
}; };
Ok([ Ok([
@ -115,15 +128,3 @@ mod test {
Ok(()) Ok(())
} }
} }
/// Use a single static keypair during testing which is signficantly faster than
/// generating dozens of keys from scratch.
pub fn generate_keypair() -> MyResult<Keypair> {
if cfg!(debug_assertions) {
static KEYPAIR: LazyLock<Keypair> =
LazyLock::new(|| generate_actor_keypair().expect("generate keypair"));
Ok(KEYPAIR.clone())
} else {
Ok(generate_actor_keypair()?)
}
}

View file

@ -123,6 +123,7 @@ pub struct DbEdit {
/// First edit of an article always has `EditVersion::default()` here /// First edit of an article always has `EditVersion::default()` here
pub previous_version_id: EditVersion, pub previous_version_id: EditVersion,
pub published: DateTime<Utc>, pub published: DateTime<Utc>,
pub pending: bool,
} }
#[derive(Deserialize, Serialize, Clone, Debug, Default)] #[derive(Deserialize, Serialize, Clone, Debug, Default)]

View file

@ -42,9 +42,16 @@ pub fn EditList(edits: Vec<EditView>, for_article: bool) -> impl IntoView {
view! { view! {
<li class="m-2 card card-compact bg-base-100 card-bordered rounded-s"> <li class="m-2 card card-compact bg-base-100 card-bordered rounded-s">
<div class="card-body"> <div class="card-body">
<a class="w-full text-lg link link-primary" href=path> <div class="flex w-full">
<a class="text-lg grow link link-primary" href=path>
{edit.edit.summary} {edit.edit.summary}
</a> </a>
<Show when=move || edit.edit.pending>
<span class="p-1 w-min rounded border-2 border-rose-300">
Pending
</span>
</Show>
</div>
<p>{second_line}</p> <p>{second_line}</p>
</div> </div>
</li> </li>

View file

@ -27,8 +27,16 @@ pub fn EditDiff() -> impl IntoView {
edit.edit.summary, edit.edit.summary,
render_date_time(edit.edit.published), render_date_time(edit.edit.published),
); );
let pending = edit.edit.pending;
view! { view! {
<h2 class="my-2 font-serif text-xl font-bold">{label}</h2> <div class="flex w-full">
<h2 class="my-2 font-serif text-xl font-bold grow">{label}</h2>
<Show when=move || pending>
<span class="p-1 w-min rounded border-2 border-rose-300 h-min">
Pending
</span>
</Show>
</div>
<p>"by " {user_link(&edit.creator)}</p> <p>"by " {user_link(&edit.creator)}</p>
<div class="p-2 my-2 bg-gray-200 rounded"> <div class="p-2 my-2 bg-gray-200 rounded">
<pre class="text-wrap"> <pre class="text-wrap">

View file

@ -32,8 +32,8 @@ impl TestData {
INIT.call_once(|| { INIT.call_once(|| {
env_logger::builder() env_logger::builder()
.filter_level(LevelFilter::Warn) .filter_level(LevelFilter::Warn)
.filter_module("activitypub_federation", LevelFilter::Info) //.filter_module("activitypub_federation", LevelFilter::Info)
.filter_module("ibis", LevelFilter::Info) //.filter_module("ibis", LevelFilter::Info)
.init(); .init();
}); });

View file

@ -28,7 +28,7 @@ async fn test_create_read_and_edit_local_article() -> Result<()> {
let TestData(alpha, beta, gamma) = TestData::start(false).await; let TestData(alpha, beta, gamma) = TestData::start(false).await;
// create article // create article
const TITLE: &'static str = "Manu_Chao"; const TITLE: &str = "Manu_Chao";
let create_form = CreateArticleForm { let create_form = CreateArticleForm {
title: "Manu Chao".to_string(), title: "Manu Chao".to_string(),
text: TEST_ARTICLE_DEFAULT_TEXT.to_string(), text: TEST_ARTICLE_DEFAULT_TEXT.to_string(),
@ -337,8 +337,8 @@ async fn test_edit_remote_article() -> Result<()> {
.starts_with(&edit_res.article.ap_id.to_string())); .starts_with(&edit_res.article.ap_id.to_string()));
// edit should be federated to beta and gamma // edit should be federated to beta and gamma
let get_res = alpha.get_article(get_article_data_alpha).await.unwrap(); let get_res = beta.get_article(get_article_data_alpha).await.unwrap();
let edits = alpha.get_article_edits(get_res.article.id).await.unwrap(); let edits = beta.get_article_edits(get_res.article.id).await.unwrap();
assert_eq!(edit_res.article.title, get_res.article.title); assert_eq!(edit_res.article.title, get_res.article.title);
assert_eq!(edits.len(), 2); assert_eq!(edits.len(), 2);
assert_eq!(edit_res.article.text, get_res.article.text); assert_eq!(edit_res.article.text, get_res.article.text);
@ -456,7 +456,7 @@ async fn test_federated_edit_conflict() -> Result<()> {
let edit_form = EditArticleForm { let edit_form = EditArticleForm {
article_id: get_res.article.id, article_id: get_res.article.id,
new_text: "Lorem Ipsum\n".to_string(), new_text: "Lorem Ipsum\n".to_string(),
summary: "summary".to_string(), summary: "first edit".to_string(),
previous_version_id: create_res.latest_version.clone(), previous_version_id: create_res.latest_version.clone(),
resolve_conflict_id: None, resolve_conflict_id: None,
}; };
@ -476,14 +476,15 @@ async fn test_federated_edit_conflict() -> Result<()> {
let edit_form = EditArticleForm { let edit_form = EditArticleForm {
article_id: resolve_res.article.id, article_id: resolve_res.article.id,
new_text: "aaaa\n".to_string(), new_text: "aaaa\n".to_string(),
summary: "summary".to_string(), summary: "second edit".to_string(),
previous_version_id: create_res.latest_version, previous_version_id: create_res.latest_version,
resolve_conflict_id: None, resolve_conflict_id: None,
}; };
let edit_res = gamma.edit_article(&edit_form).await.unwrap(); let edit_res = gamma.edit_article(&edit_form).await.unwrap();
let gamma_edits = gamma.get_article_edits(edit_res.article.id).await.unwrap(); let gamma_edits = gamma.get_article_edits(edit_res.article.id).await.unwrap();
assert_ne!(edit_form.new_text, edit_res.article.text); assert_ne!(edit_form.new_text, edit_res.article.text);
assert_eq!(1, gamma_edits.len()); assert_eq!(2, gamma_edits.len());
assert!(gamma_edits[1].edit.pending);
assert!(!edit_res.article.local); assert!(!edit_res.article.local);
assert_eq!(1, gamma.notifications_count().await.unwrap()); assert_eq!(1, gamma.notifications_count().await.unwrap());
@ -497,7 +498,7 @@ async fn test_federated_edit_conflict() -> Result<()> {
let edit_form = EditArticleForm { let edit_form = EditArticleForm {
article_id: resolve_res.article.id, article_id: resolve_res.article.id,
new_text: "aaaa\n".to_string(), new_text: "aaaa\n".to_string(),
summary: "summary".to_string(), summary: "resolve conflict".to_string(),
previous_version_id: conflict.previous_version_id.clone(), previous_version_id: conflict.previous_version_id.clone(),
resolve_conflict_id: Some(conflict.id), resolve_conflict_id: Some(conflict.id),
}; };
@ -505,6 +506,7 @@ async fn test_federated_edit_conflict() -> Result<()> {
let gamma_edits = gamma.get_article_edits(edit_res.article.id).await.unwrap(); let gamma_edits = gamma.get_article_edits(edit_res.article.id).await.unwrap();
assert_eq!(edit_form.new_text, edit_res.article.text); assert_eq!(edit_form.new_text, edit_res.article.text);
assert_eq!(3, gamma_edits.len()); assert_eq!(3, gamma_edits.len());
assert!(gamma_edits.iter().all(|e| !e.edit.pending));
assert_eq!(0, gamma.notifications_count().await.unwrap()); assert_eq!(0, gamma.notifications_count().await.unwrap());
let notifications = gamma.notifications_list().await.unwrap(); let notifications = gamma.notifications_list().await.unwrap();