From 8305680fda0e73d7dd81ededa8f92de063cb6450 Mon Sep 17 00:00:00 2001 From: Felix Ableitner Date: Wed, 24 Jan 2024 17:12:17 +0100 Subject: [PATCH] basic article editing and history --- .../2023-11-28-150402_ibis_setup/up.sql | 5 +- src/backend/api/article.rs | 12 +++ src/backend/database/conflict.rs | 3 + src/backend/database/edit.rs | 6 ++ src/backend/database/schema.rs | 11 +-- src/backend/federation/activities/mod.rs | 12 ++- src/backend/federation/activities/reject.rs | 1 + src/backend/federation/objects/edit.rs | 7 ++ src/backend/utils.rs | 2 +- src/common/mod.rs | 4 + src/frontend/api.rs | 1 + src/frontend/app.rs | 8 +- src/frontend/components/article_nav.rs | 24 +++++ src/frontend/components/credentials.rs | 5 +- src/frontend/components/mod.rs | 1 + src/frontend/mod.rs | 4 + src/frontend/pages/article.rs | 16 ---- src/frontend/pages/article/edit.rs | 93 +++++++++++++++++++ src/frontend/pages/article/history.rs | 34 +++++++ src/frontend/pages/article/mod.rs | 3 + src/frontend/pages/article/read.rs | 29 ++++++ src/frontend/pages/diff.rs | 32 +++++++ src/frontend/pages/edit_article.rs | 11 --- src/frontend/pages/mod.rs | 25 ++++- src/frontend/pages/read_article.rs | 47 ---------- 25 files changed, 303 insertions(+), 93 deletions(-) create mode 100644 src/frontend/components/article_nav.rs delete mode 100644 src/frontend/pages/article.rs create mode 100644 src/frontend/pages/article/edit.rs create mode 100644 src/frontend/pages/article/history.rs create mode 100644 src/frontend/pages/article/mod.rs create mode 100644 src/frontend/pages/article/read.rs create mode 100644 src/frontend/pages/diff.rs delete mode 100644 src/frontend/pages/edit_article.rs delete mode 100644 src/frontend/pages/read_article.rs diff --git a/migrations/2023-11-28-150402_ibis_setup/up.sql b/migrations/2023-11-28-150402_ibis_setup/up.sql index 7529199..392f1d0 100644 --- a/migrations/2023-11-28-150402_ibis_setup/up.sql +++ b/migrations/2023-11-28-150402_ibis_setup/up.sql @@ -49,13 +49,16 @@ create table edit ( hash uuid not null, ap_id varchar(255) not null unique, diff text not null, + summary text not null, article_id int REFERENCES article ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, - previous_version_id uuid not null + previous_version_id uuid not null, + created timestamptz not null ); create table conflict ( id uuid primary key, diff text not null, + summary text not null, creator_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, article_id int REFERENCES article ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, previous_version_id uuid not null diff --git a/src/backend/api/article.rs b/src/backend/api/article.rs index e36403c..a4dc9dd 100644 --- a/src/backend/api/article.rs +++ b/src/backend/api/article.rs @@ -20,6 +20,7 @@ use axum::Extension; use axum::Form; use axum::Json; use axum_macros::debug_handler; +use chrono::Utc; use diffy::create_patch; /// Create a new article with empty text, and federate it to followers. @@ -70,11 +71,19 @@ pub(in crate::backend::api) async fn edit_article( DbConflict::delete(resolve_conflict_id, &data.db_connection)?; } let original_article = DbArticle::read_view(edit_form.article_id, &data.db_connection)?; + dbg!(&edit_form.new_text, &original_article.article.text); + if edit_form.new_text == original_article.article.text { + return Err(anyhow!("Edit contains no changes").into()); + } + if edit_form.summary.is_empty() { + return Err(anyhow!("No summary given").into()); + } if edit_form.previous_version_id == original_article.latest_version { // No intermediate changes, simply submit new version submit_article_update( edit_form.new_text.clone(), + edit_form.summary.clone(), edit_form.previous_version_id, &original_article.article, user.person.id, @@ -93,6 +102,7 @@ pub(in crate::backend::api) async fn edit_article( let form = DbConflictForm { id: EditVersion::new(&patch.to_string())?, diff: patch.to_string(), + summary: edit_form.summary.clone(), creator_id: user.local_user.id, article_id: original_article.article.id, previous_version_id: previous_version.hash, @@ -159,10 +169,12 @@ pub(in crate::backend::api) async fn fork_article( let form = DbEditForm { ap_id, diff: e.diff, + summary: e.summary, creator_id: e.creator_id, article_id: article.id, hash: e.hash, previous_version_id: e.previous_version_id, + created: Utc::now(), }; DbEdit::create(&form, &data.db_connection)?; } diff --git a/src/backend/database/conflict.rs b/src/backend/database/conflict.rs index fc61b14..63aa4d1 100644 --- a/src/backend/database/conflict.rs +++ b/src/backend/database/conflict.rs @@ -25,6 +25,7 @@ use std::sync::Mutex; pub struct DbConflict { pub id: EditVersion, pub diff: String, + pub summary: String, pub creator_id: i32, pub article_id: i32, pub previous_version_id: EditVersion, @@ -35,6 +36,7 @@ pub struct DbConflict { pub struct DbConflictForm { pub id: EditVersion, pub diff: String, + pub summary: String, pub creator_id: i32, pub article_id: i32, pub previous_version_id: EditVersion, @@ -82,6 +84,7 @@ impl DbConflict { // federate the change submit_article_update( new_text, + self.summary.clone(), self.previous_version_id.clone(), &original_article, self.creator_id, diff --git a/src/backend/database/edit.rs b/src/backend/database/edit.rs index 9c53d73..a274759 100644 --- a/src/backend/database/edit.rs +++ b/src/backend/database/edit.rs @@ -3,6 +3,7 @@ use crate::backend::error::MyResult; use crate::common::EditVersion; use crate::common::{DbArticle, DbEdit}; use activitypub_federation::fetch::object_id::ObjectId; +use chrono::{DateTime, Utc}; use diesel::ExpressionMethods; use diesel::{insert_into, AsChangeset, Insertable, PgConnection, QueryDsl, RunQueryDsl}; use diffy::create_patch; @@ -16,8 +17,10 @@ pub struct DbEditForm { pub hash: EditVersion, pub ap_id: ObjectId, pub diff: String, + pub summary: String, pub article_id: i32, pub previous_version_id: EditVersion, + pub created: DateTime, } impl DbEditForm { @@ -25,6 +28,7 @@ impl DbEditForm { original_article: &DbArticle, creator_id: i32, updated_text: &str, + summary: String, previous_version_id: EditVersion, ) -> MyResult { let diff = create_patch(&original_article.text, updated_text); @@ -37,6 +41,8 @@ impl DbEditForm { creator_id, article_id: original_article.id, previous_version_id, + summary, + created: Utc::now(), }) } diff --git a/src/backend/database/schema.rs b/src/backend/database/schema.rs index 3983181..f3e1968 100644 --- a/src/backend/database/schema.rs +++ b/src/backend/database/schema.rs @@ -16,6 +16,7 @@ diesel::table! { conflict (id) { id -> Uuid, diff -> Text, + summary -> Text, creator_id -> Int4, article_id -> Int4, previous_version_id -> Uuid, @@ -30,8 +31,10 @@ diesel::table! { #[max_length = 255] ap_id -> Varchar, diff -> Text, + summary -> Text, article_id -> Int4, previous_version_id -> Uuid, + created -> Timestamptz, } } @@ -88,13 +91,6 @@ diesel::table! { } } -diesel::table! { - secret (id) { - id -> Int4, - jwt_secret -> Varchar, - } -} - diesel::joinable!(article -> instance (instance_id)); diesel::joinable!(conflict -> article (article_id)); diesel::joinable!(conflict -> local_user (creator_id)); @@ -113,5 +109,4 @@ diesel::allow_tables_to_appear_in_same_query!( jwt_secret, local_user, person, - secret, ); diff --git a/src/backend/federation/activities/mod.rs b/src/backend/federation/activities/mod.rs index c3b68b8..de20200 100644 --- a/src/backend/federation/activities/mod.rs +++ b/src/backend/federation/activities/mod.rs @@ -7,6 +7,7 @@ use crate::common::DbInstance; use crate::common::EditVersion; use crate::common::{DbArticle, DbEdit}; use activitypub_federation::config::Data; +use chrono::Utc; pub mod accept; pub mod create_article; @@ -17,12 +18,19 @@ pub mod update_remote_article; pub async fn submit_article_update( new_text: String, + summary: String, previous_version: EditVersion, original_article: &DbArticle, creator_id: i32, data: &Data, ) -> Result<(), Error> { - let form = DbEditForm::new(original_article, creator_id, &new_text, previous_version)?; + let form = DbEditForm::new( + original_article, + creator_id, + &new_text, + summary, + previous_version, + )?; if original_article.local { let edit = DbEdit::create(&form, &data.db_connection)?; let updated_article = @@ -37,8 +45,10 @@ pub async fn submit_article_update( 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, + created: Utc::now(), }; let instance = DbInstance::read(original_article.instance_id, &data.db_connection)?; UpdateRemoteArticle::send(edit, instance, data).await?; diff --git a/src/backend/federation/activities/reject.rs b/src/backend/federation/activities/reject.rs index a8a41b1..a2f330d 100644 --- a/src/backend/federation/activities/reject.rs +++ b/src/backend/federation/activities/reject.rs @@ -77,6 +77,7 @@ impl ActivityHandler for RejectEdit { let form = DbConflictForm { id: EditVersion::new(&self.object.content)?, diff: self.object.content, + summary: self.object.summary, creator_id: creator.id, article_id: article.id, previous_version_id: self.object.previous_version, diff --git a/src/backend/federation/objects/edit.rs b/src/backend/federation/objects/edit.rs index 7520125..aa3ed6b 100644 --- a/src/backend/federation/objects/edit.rs +++ b/src/backend/federation/objects/edit.rs @@ -7,6 +7,7 @@ use crate::common::{DbArticle, DbEdit}; use activitypub_federation::config::Data; use activitypub_federation::fetch::object_id::ObjectId; use activitypub_federation::traits::Object; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use url::Url; @@ -24,10 +25,12 @@ pub struct ApubEdit { kind: PatchType, pub id: ObjectId, pub content: String, + pub summary: String, pub version: EditVersion, pub previous_version: EditVersion, pub object: ObjectId, pub attributed_to: ObjectId, + pub published: DateTime, } #[async_trait::async_trait] @@ -50,10 +53,12 @@ impl Object for DbEdit { kind: PatchType::Patch, id: self.ap_id, content: self.diff, + summary: self.summary, version: self.hash, previous_version: self.previous_version_id, object: article.ap_id, attributed_to: creator.ap_id, + published: self.created, }) } @@ -72,9 +77,11 @@ impl Object for DbEdit { creator_id: creator.id, ap_id: json.id, diff: json.content, + summary: json.summary, article_id: article.id, hash: json.version, previous_version_id: json.previous_version, + created: json.published, }; let edit = DbEdit::create(&form, &data.db_connection)?; Ok(edit) diff --git a/src/backend/utils.rs b/src/backend/utils.rs index 0188104..3b8c67a 100644 --- a/src/backend/utils.rs +++ b/src/backend/utils.rs @@ -9,7 +9,7 @@ use url::{ParseError, Url}; pub fn generate_activity_id(domain: &Url) -> Result { let port = domain.port().unwrap(); - let domain = domain.domain().unwrap(); + let domain = domain.host_str().unwrap(); let id: String = thread_rng() .sample_iter(&Alphanumeric) .take(7) diff --git a/src/common/mod.rs b/src/common/mod.rs index 8d176a1..f245b6e 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -60,9 +60,11 @@ pub struct DbEdit { #[cfg(not(feature = "ssr"))] pub ap_id: String, pub diff: String, + pub summary: String, pub article_id: i32, /// First edit of an article always has `EditVersion::default()` here pub previous_version_id: EditVersion, + pub created: DateTime, } /// The version hash of a specific edit. Generated by taking an SHA256 hash of the diff @@ -134,6 +136,8 @@ pub struct EditArticleData { /// Full, new text of the article. A diff against `previous_version` is generated on the backend /// side to handle conflicts. pub new_text: String, + /// What was changed + pub summary: String, /// The version that this edit is based on, ie [DbArticle.latest_version] or /// [ApiConflict.previous_version] pub previous_version_id: EditVersion, diff --git a/src/frontend/api.rs b/src/frontend/api.rs index 60b3a39..394825d 100644 --- a/src/frontend/api.rs +++ b/src/frontend/api.rs @@ -71,6 +71,7 @@ impl ApiClient { let edit_form = EditArticleData { article_id: article.article.id, new_text, + summary: "initial text".to_string(), previous_version_id: article.latest_version, resolve_conflict_id: None, }; diff --git a/src/frontend/app.rs b/src/frontend/app.rs index cf3a335..1ea7d99 100644 --- a/src/frontend/app.rs +++ b/src/frontend/app.rs @@ -1,9 +1,11 @@ use crate::common::LocalUserView; use crate::frontend::api::ApiClient; use crate::frontend::components::nav::Nav; -use crate::frontend::pages::edit_article::EditArticle; +use crate::frontend::pages::article::edit::EditArticle; +use crate::frontend::pages::article::history::ArticleHistory; +use crate::frontend::pages::article::read::ReadArticle; +use crate::frontend::pages::diff::EditDiff; use crate::frontend::pages::login::Login; -use crate::frontend::pages::read_article::ReadArticle; use crate::frontend::pages::register::Register; use crate::frontend::pages::Page; use leptos::{ @@ -77,6 +79,8 @@ pub fn App() -> impl IntoView { + + diff --git a/src/frontend/components/article_nav.rs b/src/frontend/components/article_nav.rs new file mode 100644 index 0000000..bef1672 --- /dev/null +++ b/src/frontend/components/article_nav.rs @@ -0,0 +1,24 @@ +use crate::common::ArticleView; +use crate::frontend::app::GlobalState; +use leptos::*; +use leptos_router::*; + +#[component] +pub fn ArticleNav(article: Resource) -> impl IntoView { + let global_state = use_context::>().unwrap(); + view! { + + {move || article.get().map(|article| { + let title = article.article.title; + view!{ + + }})} + + } +} diff --git a/src/frontend/components/credentials.rs b/src/frontend/components/credentials.rs index 78a59d2..81e0e31 100644 --- a/src/frontend/components/credentials.rs +++ b/src/frontend/components/credentials.rs @@ -65,10 +65,9 @@ pub fn CredentialsForm(
+
} diff --git a/src/frontend/components/mod.rs b/src/frontend/components/mod.rs index 2d84ec9..3cde812 100644 --- a/src/frontend/components/mod.rs +++ b/src/frontend/components/mod.rs @@ -1,2 +1,3 @@ +pub mod article_nav; pub(crate) mod credentials; pub mod nav; diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index 6dc0a35..07bdcb9 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -3,3 +3,7 @@ pub mod app; mod components; pub mod error; pub mod pages; + +#[cfg(feature = "hydrate")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn hydrate() {} diff --git a/src/frontend/pages/article.rs b/src/frontend/pages/article.rs deleted file mode 100644 index 6d8f56d..0000000 --- a/src/frontend/pages/article.rs +++ /dev/null @@ -1,16 +0,0 @@ -use leptos::*; -use leptos_router::*; - -#[component] -pub fn Article() -> impl IntoView { - view! { - - } -} diff --git a/src/frontend/pages/article/edit.rs b/src/frontend/pages/article/edit.rs new file mode 100644 index 0000000..345abe9 --- /dev/null +++ b/src/frontend/pages/article/edit.rs @@ -0,0 +1,93 @@ +use crate::common::EditArticleData; +use crate::frontend::app::GlobalState; +use crate::frontend::components::article_nav::ArticleNav; +use crate::frontend::pages::article_resource; +use leptos::*; +use leptos_router::use_params_map; + +#[component] +pub fn EditArticle() -> impl IntoView { + let params = use_params_map(); + let title = params.get_untracked().get("title").cloned(); + let article = article_resource(title.unwrap()); + + let (text, set_text) = create_signal(String::new()); + // TODO: set initial text, otherwise submit with no changes results in empty text + //article.with(|article| set_text.update(article.as_ref().unwrap().article.text.clone())); + let (summary, set_summary) = create_signal(String::new()); + let (edit_response, set_edit_response) = create_signal(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): &(String, String)| { + let new_text = new_text.clone(); + let summary = summary.clone(); + async move { + let form = EditArticleData { + article_id: article.get().unwrap().article.id, + new_text, + summary, + previous_version_id: article.get().unwrap().latest_version, + resolve_conflict_id: None, + }; + set_wait_for_response.update(|w| *w = true); + let res = GlobalState::api_client().edit_article(&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); + } + Err(err) => { + let msg = err.0.to_string(); + log::warn!("Unable to edit: {msg}"); + set_edit_error.update(|e| *e = Some(msg)); + } + } + } + }); + + view! { + + { + move || article.get().map(|article| + view! { +
+

{article.article.title.replace('_', " ")}

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

{err}

} + }) + }} + + + } + ) + } + + }}> + Edit successful! +
+ } +} diff --git a/src/frontend/pages/article/history.rs b/src/frontend/pages/article/history.rs new file mode 100644 index 0000000..d3d9dab --- /dev/null +++ b/src/frontend/pages/article/history.rs @@ -0,0 +1,34 @@ +use crate::frontend::components::article_nav::ArticleNav; +use crate::frontend::pages::article_resource; +use leptos::*; +use leptos_router::*; + +#[component] +pub fn ArticleHistory() -> impl IntoView { + let params = use_params_map(); + let title = params.get().get("title").cloned(); + let article = article_resource(title.unwrap()); + + view! { + + { + move || article.get().map(|article| { + let title = article.article.title; + view! { +
+

{title.replace('_', " ")}

+ { + article.edits.into_iter().rev().map(|edit| { + let path = format!("/article/{title}/diff/{}", edit.hash.0); + // TODO: need to return username from backend and show it + let label = format!("{} ({})", edit.summary, edit.created.to_rfc2822()); + view! {
  • {label}
  • } + }).collect::>() + } +
    + } + }) + } +
    + } +} diff --git a/src/frontend/pages/article/mod.rs b/src/frontend/pages/article/mod.rs new file mode 100644 index 0000000..2e0a170 --- /dev/null +++ b/src/frontend/pages/article/mod.rs @@ -0,0 +1,3 @@ +pub mod edit; +pub mod history; +pub mod read; diff --git a/src/frontend/pages/article/read.rs b/src/frontend/pages/article/read.rs new file mode 100644 index 0000000..f67c07d --- /dev/null +++ b/src/frontend/pages/article/read.rs @@ -0,0 +1,29 @@ +use crate::frontend::components::article_nav::ArticleNav; +use crate::frontend::pages::article_resource; +use leptos::*; +use leptos_router::*; + +#[component] +pub fn ReadArticle() -> impl IntoView { + let params = use_params_map(); + let title = params + .get_untracked() + .get("title") + .cloned() + .unwrap_or("Main_Page".to_string()); + let article = article_resource(title); + + view! { + + { + move || article.get().map(|article| + view! { +
    +

    {article.article.title.replace('_', " ")}

    +
    {article.article.text}
    +
    + }) + } +
    + } +} diff --git a/src/frontend/pages/diff.rs b/src/frontend/pages/diff.rs new file mode 100644 index 0000000..9de50bd --- /dev/null +++ b/src/frontend/pages/diff.rs @@ -0,0 +1,32 @@ +use crate::frontend::components::article_nav::ArticleNav; +use crate::frontend::pages::article_resource; +use leptos::*; +use leptos_router::*; + +#[component] +pub fn EditDiff() -> impl IntoView { + let params = use_params_map(); + let title = params.get_untracked().get("title").cloned().unwrap(); + let article = article_resource(title); + + view! { + + { + move || article.get().map(|article| { + let hash = params + .get_untracked() + .get("hash") + .cloned().unwrap(); + let edit = article.edits.iter().find(|e| e.hash.0.to_string() == hash).unwrap(); + // TODO: need to show username + view! { +
    +

    {article.article.title.replace('_', " ")}

    +
    {edit.diff.clone()}
    +
    + } + }) + } +
    + } +} diff --git a/src/frontend/pages/edit_article.rs b/src/frontend/pages/edit_article.rs deleted file mode 100644 index 5a92a36..0000000 --- a/src/frontend/pages/edit_article.rs +++ /dev/null @@ -1,11 +0,0 @@ -use leptos::*; - -#[component] -pub fn EditArticle() -> impl IntoView { - view! { -
    -

    Title...

    - -
    - } -} diff --git a/src/frontend/pages/mod.rs b/src/frontend/pages/mod.rs index e017adb..87df842 100644 --- a/src/frontend/pages/mod.rs +++ b/src/frontend/pages/mod.rs @@ -1,7 +1,10 @@ -pub mod article; -pub mod edit_article; +use crate::common::{ArticleView, GetArticleData}; +use crate::frontend::app::GlobalState; +use leptos::{create_resource, Resource}; + +pub(crate) mod article; +pub(crate) mod diff; pub mod login; -pub mod read_article; pub mod register; #[derive(Debug, Clone, Copy, Default)] @@ -21,3 +24,19 @@ impl Page { } } } + +fn article_resource(title: String) -> Resource { + create_resource( + move || title.clone(), + move |title| async move { + GlobalState::api_client() + .get_article(GetArticleData { + title: Some(title), + instance_id: None, + id: None, + }) + .await + .unwrap() + }, + ) +} diff --git a/src/frontend/pages/read_article.rs b/src/frontend/pages/read_article.rs deleted file mode 100644 index 36543de..0000000 --- a/src/frontend/pages/read_article.rs +++ /dev/null @@ -1,47 +0,0 @@ -use crate::common::GetArticleData; -use crate::frontend::app::GlobalState; -use leptos::*; -use leptos_router::*; - -#[component] -pub fn ReadArticle() -> impl IntoView { - let params = use_params_map(); - let article = create_resource( - move || { - params - .get() - .get("title") - .cloned() - .unwrap_or("Main_Page".to_string()) - }, - move |title| async move { - GlobalState::api_client() - .get_article(GetArticleData { - title: Some(title), - instance_id: None, - id: None, - }) - .await - .unwrap() - }, - ); - - let global_state = use_context::>().unwrap(); - let (count, set_count) = create_signal(0); - view! { - - {move || article.get().map(|article| - view! { -
    -

    {article.article.title.replace('_', " ")}

    - - - -
    {article.article.text}
    -
    - })} -
    - } -}