From a15a42b977d2cde17958a1ecaf368c11b8876ff1 Mon Sep 17 00:00:00 2001 From: Felix Ableitner Date: Wed, 14 Feb 2024 16:09:24 +0100 Subject: [PATCH] Handle conflicts in frontend --- README.md | 2 +- .../2023-11-28-150402_ibis_setup/up.sql | 3 +- src/backend/api/article.rs | 2 +- src/backend/database/conflict.rs | 13 ++-- src/backend/database/schema.rs | 3 +- src/backend/federation/activities/reject.rs | 2 +- src/common/mod.rs | 6 +- src/frontend/pages/article/edit.rs | 74 ++++++++++++++----- tests/test.rs | 2 +- 9 files changed, 75 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index f735f47..bf2851d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ You can start by reading the main page which is rendered from Markdown. In the " To continue, register a new account and you are logged in immediately without further confirmation. If you are admin of a newly created instance, login to the automatically created admin account instead. Login details are specified in the config file (by default user `ibis` and password also `ibis`). -On a new instance only the default Main Page will be shown. Use "Create Article" to create a new one. You have to enter the title, text and a summary of the edit. Afterwards press the submit button, and you are redirected to the new article. You can also make changes to existing articles with the "Edit" button at the top. For remote articles, there is additionally a "Fork" option under the "Actions" tab. This allows copying a remote article including the full change history to the local instance. It can be useful if the original instance is dead, or if there are disagreements how the article should be written. +On a new instance only the default Main Page will be shown. Use "Create Article" to create a new one. You have to enter the title, text and a summary of the edit. Afterwards press the submit button, and you are redirected to the new article. You can also make changes to existing articles with the "Edit" button at the top. If multiple users attempt to edit an article at the same time, Ibis will attempt to merge the changes automatically. If this is unsuccessful, the user has to perform a manual merge (again like in git). For remote articles, there is additionally a "Fork" option under the "Actions" tab. This allows copying a remote article including the full change history to the local instance. It can be useful if the original instance is dead, or if there are disagreements how the article should be written. To kickstart federation, paste the domain of a remote instance into the search field, eg `https://example.com`. This will fetch the instance data over Activitypub, and also fetch all articles to make them available locally. The search page will show a link to the instance details page. Here you can follow the instance, so that new articles and edits are automatically federated to your local instance. You can also fetch individual articles from remote instances by pasting the URL into the search field. diff --git a/migrations/2023-11-28-150402_ibis_setup/up.sql b/migrations/2023-11-28-150402_ibis_setup/up.sql index 9c46b6a..4a022fd 100644 --- a/migrations/2023-11-28-150402_ibis_setup/up.sql +++ b/migrations/2023-11-28-150402_ibis_setup/up.sql @@ -59,7 +59,8 @@ create table edit ( ); create table conflict ( - id uuid primary key, + id serial primary key, + hash uuid not null, diff text not null, summary text not null, creator_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, diff --git a/src/backend/api/article.rs b/src/backend/api/article.rs index 7d280e5..52bc493 100644 --- a/src/backend/api/article.rs +++ b/src/backend/api/article.rs @@ -119,7 +119,7 @@ pub(in crate::backend::api) async fn edit_article( let previous_version = DbEdit::read(&edit_form.previous_version_id, &data.db_connection)?; let form = DbConflictForm { - id: EditVersion::new(&patch.to_string()), + hash: EditVersion::new(&patch.to_string()), diff: patch.to_string(), summary: edit_form.summary.clone(), creator_id: user.local_user.id, diff --git a/src/backend/database/conflict.rs b/src/backend/database/conflict.rs index c1f9698..1aee954 100644 --- a/src/backend/database/conflict.rs +++ b/src/backend/database/conflict.rs @@ -23,7 +23,8 @@ use std::sync::Mutex; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Queryable, Selectable, Identifiable)] #[diesel(table_name = conflict, check_for_backend(diesel::pg::Pg), belongs_to(DbArticle, foreign_key = article_id))] pub struct DbConflict { - pub id: EditVersion, + pub id: i32, + pub hash: EditVersion, pub diff: String, pub summary: String, pub creator_id: i32, @@ -34,7 +35,7 @@ pub struct DbConflict { #[derive(Debug, Clone, Insertable)] #[diesel(table_name = conflict, check_for_backend(diesel::pg::Pg))] pub struct DbConflictForm { - pub id: EditVersion, + pub hash: EditVersion, pub diff: String, pub summary: String, pub creator_id: i32, @@ -58,7 +59,7 @@ impl DbConflict { } /// Delete a merge conflict after it is resolved. - pub fn delete(id: EditVersion, conn: &Mutex) -> MyResult { + pub fn delete(id: i32, conn: &Mutex) -> MyResult { let mut conn = conn.lock().unwrap(); Ok(delete(conflict::table.find(id)).get_result(conn.deref_mut())?) } @@ -88,14 +89,16 @@ impl DbConflict { data, ) .await?; - DbConflict::delete(self.id.clone(), &data.db_connection)?; + DbConflict::delete(self.id, &data.db_connection)?; Ok(None) } Err(three_way_merge) => { // there is a merge conflict, user needs to do three-way-merge Ok(Some(ApiConflict { - id: self.id.clone(), + id: self.id, + hash: self.hash.clone(), three_way_merge, + summary: self.summary.clone(), article_id: original_article.id, previous_version_id: original_article .latest_edit_version(&data.db_connection)?, diff --git a/src/backend/database/schema.rs b/src/backend/database/schema.rs index d2997c3..e97310f 100644 --- a/src/backend/database/schema.rs +++ b/src/backend/database/schema.rs @@ -14,7 +14,8 @@ diesel::table! { diesel::table! { conflict (id) { - id -> Uuid, + id -> Int4, + hash -> Uuid, diff -> Text, summary -> Text, creator_id -> Int4, diff --git a/src/backend/federation/activities/reject.rs b/src/backend/federation/activities/reject.rs index c8e7e10..9419ac7 100644 --- a/src/backend/federation/activities/reject.rs +++ b/src/backend/federation/activities/reject.rs @@ -75,7 +75,7 @@ impl ActivityHandler for RejectEdit { let article = self.object.object.dereference(data).await?; let creator = self.object.attributed_to.dereference(data).await?; let form = DbConflictForm { - id: EditVersion::new(&self.object.content), + hash: EditVersion::new(&self.object.content), diff: self.object.content, summary: self.object.summary, creator_id: creator.id, diff --git a/src/common/mod.rs b/src/common/mod.rs index 1ab76b8..27e6e80 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -187,7 +187,7 @@ pub struct EditArticleData { /// [ApiConflict.previous_version] pub previous_version_id: EditVersion, /// If you are resolving a conflict, pass the id to delete conflict from the database - pub resolve_conflict_id: Option, + pub resolve_conflict_id: Option, } #[derive(Deserialize, Serialize)] @@ -213,8 +213,10 @@ pub struct ResolveObject { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct ApiConflict { - pub id: EditVersion, + pub id: i32, + pub hash: EditVersion, pub three_way_merge: String, + pub summary: String, pub article_id: i32, pub previous_version_id: EditVersion, } diff --git a/src/frontend/pages/article/edit.rs b/src/frontend/pages/article/edit.rs index 0896df0..a316e6d 100644 --- a/src/frontend/pages/article/edit.rs +++ b/src/frontend/pages/article/edit.rs @@ -1,41 +1,72 @@ -use crate::common::{ArticleView, EditArticleData}; +use crate::common::{ApiConflict, ArticleView, EditArticleData}; use crate::frontend::app::GlobalState; use crate::frontend::article_title; use crate::frontend::components::article_nav::ArticleNav; use crate::frontend::pages::article_resource; use leptos::*; +#[derive(Clone, PartialEq)] +enum EditResponse { + None, + Success, + Conflict(ApiConflict), +} + #[component] pub fn EditArticle() -> impl IntoView { let article = article_resource(); let (text, set_text) = create_signal(String::new()); let (summary, set_summary) = create_signal(String::new()); - let (edit_response, set_edit_response) = create_signal(None::<()>); + let (edit_response, set_edit_response) = create_signal(EditResponse::None); let (edit_error, set_edit_error) = create_signal(None::); let (wait_for_response, set_wait_for_response) = create_signal(false); let button_is_disabled = Signal::derive(move || wait_for_response.get() || summary.get().is_empty()); let submit_action = create_action( - move |(new_text, summary, article): &(String, String, ArticleView)| { + move |(new_text, summary, article, edit_response): &( + String, + String, + ArticleView, + EditResponse, + )| { let new_text = new_text.clone(); let summary = summary.clone(); let article = article.clone(); + let resolve_conflict_id = match edit_response { + EditResponse::Conflict(conflict) => Some(conflict.id), + _ => None, + }; + let previous_version_id = match edit_response { + EditResponse::Conflict(conflict) => conflict.previous_version_id.clone(), + _ => article.latest_version, + }; async move { + set_edit_error.update(|e| *e = None); let form = EditArticleData { article_id: article.article.id, new_text, summary, - previous_version_id: article.latest_version, - resolve_conflict_id: None, + previous_version_id, + resolve_conflict_id, }; set_wait_for_response.update(|w| *w = true); - let res = GlobalState::api_client().edit_article(&form).await; + let res = GlobalState::api_client() + .edit_article_with_conflict(&form) + .await; set_wait_for_response.update(|w| *w = false); match res { - Ok(_res) => { - set_edit_response.update(|v| *v = Some(())); - set_edit_error.update(|e| *e = None); + Ok(Some(conflict)) => { + set_edit_response.update(|v| *v = EditResponse::Conflict(conflict)); + set_edit_error.update(|e| { + *e = Some( + "There was an edit conflict. Resolve it manually and resubmit." + .to_string(), + ) + }); + } + Ok(None) => { + set_edit_response.update(|v| *v = EditResponse::Success); } Err(err) => { let msg = err.0.to_string(); @@ -50,16 +81,27 @@ pub fn EditArticle() -> impl IntoView { view! { { - move || article.get().map(|article| { + move || article.get().map(|mut article| { + if let EditResponse::Conflict(conflict) = edit_response.get() { + article.article.text = conflict.three_way_merge; + set_summary.set(conflict.summary); + } // set initial text, otherwise submit with no changes results in empty text set_text.set(article.article.text.clone()); view! {

{article_title(&article.article)}

+ {move || { + edit_error + .get() + .map(|err| { + view! {

{err}

} + }) + }}
- {move || { - edit_error - .get() - .map(|err| { - view! {

{err}

} - }) - }} } diff --git a/tests/test.rs b/tests/test.rs index 5923e47..69d388a 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -446,7 +446,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> { new_text: "aaaa\n".to_string(), summary: "summary".to_string(), previous_version_id: conflicts[0].previous_version_id.clone(), - resolve_conflict_id: Some(conflicts[0].id.clone()), + resolve_conflict_id: Some(conflicts[0].id), }; let edit_res = data.gamma.edit_article(&edit_form).await?; assert_eq!(edit_form.new_text, edit_res.article.text);