basic article editing and history

This commit is contained in:
Felix Ableitner 2024-01-24 17:12:17 +01:00
parent e0cc23c0bc
commit 8305680fda
25 changed files with 303 additions and 93 deletions

View File

@ -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

View File

@ -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)?;
}

View File

@ -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,

View File

@ -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<DbEdit>,
pub diff: String,
pub summary: String,
pub article_id: i32,
pub previous_version_id: EditVersion,
pub created: DateTime<Utc>,
}
impl DbEditForm {
@ -25,6 +28,7 @@ impl DbEditForm {
original_article: &DbArticle,
creator_id: i32,
updated_text: &str,
summary: String,
previous_version_id: EditVersion,
) -> MyResult<Self> {
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(),
})
}

View File

@ -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,
);

View File

@ -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<MyDataHandle>,
) -> 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?;

View File

@ -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,

View File

@ -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<DbEdit>,
pub content: String,
pub summary: String,
pub version: EditVersion,
pub previous_version: EditVersion,
pub object: ObjectId<DbArticle>,
pub attributed_to: ObjectId<DbPerson>,
pub published: DateTime<Utc>,
}
#[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)

View File

@ -9,7 +9,7 @@ use url::{ParseError, Url};
pub fn generate_activity_id(domain: &Url) -> Result<Url, ParseError> {
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)

View File

@ -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<Utc>,
}
/// 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,

View File

@ -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,
};

View File

@ -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 {
<Route path={Page::Home.path()} view=ReadArticle/>
<Route path="/article/:title" view=ReadArticle/>
<Route path="/article/:title/edit" view=EditArticle/>
<Route path="/article/:title/history" view=ArticleHistory/>
<Route path="/article/:title/diff/:hash" view=EditDiff/>
<Route path={Page::Login.path()} view=Login/>
<Route path={Page::Register.path()} view=Register/>
</Routes>

View File

@ -0,0 +1,24 @@
use crate::common::ArticleView;
use crate::frontend::app::GlobalState;
use leptos::*;
use leptos_router::*;
#[component]
pub fn ArticleNav(article: Resource<String, ArticleView>) -> impl IntoView {
let global_state = use_context::<RwSignal<GlobalState>>().unwrap();
view! {
<Suspense fallback=|| view! { "Loading..." }>
{move || article.get().map(|article| {
let title = article.article.title;
view!{
<nav class="inner">
<A href={format!("/article/{title}")}>"Read"</A>
<A href={format!("/article/{title}/history")}>"History"</A>
<Show when=move || global_state.with(|state| state.my_profile.is_some())>
<A href={format!("/article/{title}/edit")}>"Edit"</A>
</Show>
</nav>
}})}
</Suspense>
}
}

View File

@ -65,10 +65,9 @@ pub fn CredentialsForm(
<div>
<button
prop:disabled=move || button_is_disabled.get()
on:click=move |_| dispatch_action()
>
on:click=move |_| dispatch_action()>
{action_label}
</button>
</button>
</div>
</form>
}

View File

@ -1,2 +1,3 @@
pub mod article_nav;
pub(crate) mod credentials;
pub mod nav;

View File

@ -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() {}

View File

@ -1,16 +0,0 @@
use leptos::*;
use leptos_router::*;
#[component]
pub fn Article() -> impl IntoView {
view! {
<nav class="inner">
<li>
<A href="read">"Read"</A>
</li>
<li>
<A href="edit">"Edit"</A>
</li>
</nav>
}
}

View File

@ -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::<String>);
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! {
<ArticleNav article=article.clone()/>
<Show
when=move || edit_response.get().is_some()
fallback=move || {
view! {
<Suspense fallback=|| view! { "Loading..." }> {
move || article.get().map(|article|
view! {
<div class="item-view">
<h1>{article.article.title.replace('_', " ")}</h1>
<textarea on:keyup=move |ev| {
let val = event_target_value(&ev);
set_text.update(|p| *p = val);
}>
{article.article.text}
</textarea>
</div>
{move || {
edit_error
.get()
.map(|err| {
view! { <p style="color:red;">{err}</p> }
})
}}
<input type="text" on:keyup=move |ev| {
let val = event_target_value(&ev);
set_summary.update(|p| *p = val);
}/>
<button
prop:disabled=move || button_is_disabled.get()
on:click=move |_| submit_action.dispatch((text.get(), summary.get()))>
Submit
</button>
}
)
}
</Suspense>
}}>
Edit successful!
</Show>
}
}

View File

@ -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! {
<ArticleNav article=article.clone()/>
<Suspense fallback=|| view! { "Loading..." }> {
move || article.get().map(|article| {
let title = article.article.title;
view! {
<div class="item-view">
<h1>{title.replace('_', " ")}</h1>
{
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! {<li><a href={path}>{label}</a></li> }
}).collect::<Vec<_>>()
}
</div>
}
})
}
</Suspense>
}
}

View File

@ -0,0 +1,3 @@
pub mod edit;
pub mod history;
pub mod read;

View File

@ -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! {
<ArticleNav article=article.clone()/>
<Suspense fallback=|| view! { "Loading..." }> {
move || article.get().map(|article|
view! {
<div class="item-view">
<h1>{article.article.title.replace('_', " ")}</h1>
<div>{article.article.text}</div>
</div>
})
}
</Suspense>
}
}

View File

@ -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! {
<ArticleNav article=article.clone()/>
<Suspense fallback=|| view! { "Loading..." }> {
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! {
<div class="item-view">
<h1>{article.article.title.replace('_', " ")}</h1>
<pre>{edit.diff.clone()}</pre>
</div>
}
})
}
</Suspense>
}
}

View File

@ -1,11 +0,0 @@
use leptos::*;
#[component]
pub fn EditArticle() -> impl IntoView {
view! {
<div class="item-view">
<h1>Title...</h1>
<textarea></textarea>
</div>
}
}

View File

@ -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<String, ArticleView> {
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()
},
)
}

View File

@ -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::<RwSignal<GlobalState>>().unwrap();
let (count, set_count) = create_signal(0);
view! {
<Suspense fallback=|| view! { "Loading..." }>
{move || article.get().map(|article|
view! {
<div class="item-view">
<h1>{article.article.title.replace('_', " ")}</h1>
<Show when=move || global_state.with(|state| state.my_profile.is_some())>
<button on:click=move |_| {
set_count.update(|n| *n += 1);
}>Edit {move || count.get()}</button>
</Show>
<div>{article.article.text}</div>
</div>
})}
</Suspense>
}
}