Implement editing

This commit is contained in:
Magnus Hoff 2017-09-05 17:07:57 +02:00
parent 60a87d1898
commit 1a5b39b3a1
7 changed files with 142 additions and 33 deletions

13
Cargo.lock generated
View file

@ -19,6 +19,7 @@ dependencies = [
"regex 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", "regex 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)",
"serde 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)",
"serde_derive 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)", "serde_derive 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)",
"serde_json 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)",
"serde_urlencoded 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", "serde_urlencoded 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)",
"tokio-io 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", "tokio-io 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)",
"tokio-proto 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", "tokio-proto 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
@ -572,6 +573,17 @@ dependencies = [
"synom 0.11.3 (registry+https://github.com/rust-lang/crates.io-index)", "synom 0.11.3 (registry+https://github.com/rust-lang/crates.io-index)",
] ]
[[package]]
name = "serde_json"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"dtoa 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)",
"itoa 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
"num-traits 0.1.40 (registry+https://github.com/rust-lang/crates.io-index)",
"serde 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.5.1" version = "0.5.1"
@ -893,6 +905,7 @@ dependencies = [
"checksum serde 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)" = "f7726f29ddf9731b17ff113c461e362c381d9d69433f79de4f3dd572488823e9" "checksum serde 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)" = "f7726f29ddf9731b17ff113c461e362c381d9d69433f79de4f3dd572488823e9"
"checksum serde_derive 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)" = "cf823e706be268e73e7747b147aa31c8f633ab4ba31f115efb57e5047c3a76dd" "checksum serde_derive 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)" = "cf823e706be268e73e7747b147aa31c8f633ab4ba31f115efb57e5047c3a76dd"
"checksum serde_derive_internals 0.15.1 (registry+https://github.com/rust-lang/crates.io-index)" = "37aee4e0da52d801acfbc0cc219eb1eda7142112339726e427926a6f6ee65d3a" "checksum serde_derive_internals 0.15.1 (registry+https://github.com/rust-lang/crates.io-index)" = "37aee4e0da52d801acfbc0cc219eb1eda7142112339726e427926a6f6ee65d3a"
"checksum serde_json 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "d243424e06f9f9c39e3cd36147470fd340db785825e367625f79298a6ac6b7ac"
"checksum serde_urlencoded 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ce0fd303af908732989354c6f02e05e2e6d597152870f2c6990efb0577137480" "checksum serde_urlencoded 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ce0fd303af908732989354c6f02e05e2e6d597152870f2c6990efb0577137480"
"checksum slab 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "17b4fcaed89ab08ef143da37bc52adbcc04d4a69014f4c1208d6b51f0c47bc23" "checksum slab 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "17b4fcaed89ab08ef143da37bc52adbcc04d4a69014f4c1208d6b51f0c47bc23"
"checksum smallvec 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "4c8cbcd6df1e117c2210e13ab5109635ad68a929fcbb8964dc965b76cb5ee013" "checksum smallvec 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "4c8cbcd6df1e117c2210e13ab5109635ad68a929fcbb8964dc965b76cb5ee013"

View file

@ -12,6 +12,7 @@ tokio-service = "0.1"
serde_derive = "1.0.0" serde_derive = "1.0.0"
serde = "1.0.0" serde = "1.0.0"
serde_urlencoded = "0.5.0" serde_urlencoded = "0.5.0"
serde_json = "1.0"
r2d2 = "0.7" r2d2 = "0.7"
r2d2-diesel = "0.16" r2d2-diesel = "0.16"
regex = "0.2" regex = "0.2"

View file

@ -11,6 +11,7 @@ extern crate hyper;
extern crate pulldown_cmark; extern crate pulldown_cmark;
extern crate r2d2; extern crate r2d2;
extern crate r2d2_diesel; extern crate r2d2_diesel;
extern crate serde_json;
extern crate serde_urlencoded; extern crate serde_urlencoded;
use std::net::SocketAddr; use std::net::SocketAddr;

View file

@ -5,6 +5,8 @@ use hyper;
use hyper::header::ContentType; use hyper::header::ContentType;
use hyper::mime; use hyper::mime;
use hyper::server::*; use hyper::server::*;
use serde_json;
use serde_urlencoded;
use models; use models;
use state::State; use state::State;
@ -47,6 +49,7 @@ fn render_markdown(src: &str) -> String {
lazy_static! { lazy_static! {
static ref TEXT_HTML: mime::Mime = "text/html;charset=utf-8".parse().unwrap(); static ref TEXT_HTML: mime::Mime = "text/html;charset=utf-8".parse().unwrap();
static ref APPLICATION_JSON: mime::Mime = "application/json".parse().unwrap();
} }
#[derive(BartDisplay)] #[derive(BartDisplay)]
@ -70,7 +73,7 @@ struct WikiLookup {
impl Lookup for WikiLookup { impl Lookup for WikiLookup {
type Resource = ArticleResource; type Resource = ArticleResource;
type Error = Box<::std::error::Error + Send>; type Error = Box<::std::error::Error + Send + Sync>;
type Future = futures::future::FutureResult<Option<Self::Resource>, Self::Error>; type Future = futures::future::FutureResult<Option<Self::Resource>, Self::Error>;
fn lookup(&self, path: &str, _query: Option<&str>, _fragment: Option<&str>) -> Self::Future { fn lookup(&self, path: &str, _query: Option<&str>, _fragment: Option<&str>) -> Self::Future {
@ -85,7 +88,7 @@ impl Lookup for WikiLookup {
if let Ok(article_id) = slug.parse() { if let Ok(article_id) = slug.parse() {
match self.state.get_article_revision_by_id(article_id) { match self.state.get_article_revision_by_id(article_id) {
Ok(Some(article)) => { Ok(Some(article)) => {
futures::finished(Some(ArticleResource::new(article))) futures::finished(Some(ArticleResource::new(self.state.clone(), article)))
}, },
Ok(None) => futures::finished(None), Ok(None) => futures::finished(None),
Err(err) => futures::failed(err), Err(err) => futures::failed(err),
@ -97,14 +100,13 @@ impl Lookup for WikiLookup {
} }
struct ArticleResource { struct ArticleResource {
state: State,
data: models::ArticleRevision, data: models::ArticleRevision,
} }
impl ArticleResource { impl ArticleResource {
fn new(data: models::ArticleRevision) -> Self { fn new(state: State, data: models::ArticleRevision) -> Self {
Self { Self { state, data }
data
}
} }
} }
@ -114,14 +116,14 @@ impl Resource for ArticleResource {
vec![Options, Head, Get, Put] vec![Options, Head, Get, Put]
} }
fn head(&self) -> futures::BoxFuture<Response, Box<::std::error::Error + Send>> { fn head(&self) -> futures::BoxFuture<Response, Box<::std::error::Error + Send + Sync>> {
futures::finished(Response::new() futures::finished(Response::new()
.with_status(hyper::StatusCode::Ok) .with_status(hyper::StatusCode::Ok)
.with_header(ContentType(TEXT_HTML.clone())) .with_header(ContentType(TEXT_HTML.clone()))
).boxed() ).boxed()
} }
fn get(self) -> futures::BoxFuture<Response, Box<::std::error::Error + Send>> { fn get(self) -> futures::BoxFuture<Response, Box<::std::error::Error + Send + Sync>> {
use chrono::{self, TimeZone, Local}; use chrono::{self, TimeZone, Local};
#[derive(BartDisplay)] #[derive(BartDisplay)]
@ -152,8 +154,52 @@ impl Resource for ArticleResource {
).boxed() ).boxed()
} }
fn put(self, body: &[u8]) -> futures::BoxFuture<Response, Box<::std::error::Error + Send>> { fn put<S: 'static + futures::Stream<Item=hyper::Chunk, Error=hyper::Error> + Send + Sync>(self, body: S) -> futures::BoxFuture<Response, Box<::std::error::Error + Send + Sync>> {
unimplemented!() // TODO Check incoming Content-Type
use chrono::{TimeZone, Local};
#[derive(Deserialize)]
struct UpdateArticle {
base_revision: i32,
body: String,
}
#[derive(Serialize)]
struct PutResponse<'a> {
revision: i32,
rendered: &'a str,
created: &'a str,
}
body
.concat2()
.map_err(|x| Box::new(x) as Box<::std::error::Error + Send + Sync>)
.and_then(move |body| {
let update: UpdateArticle = match serde_urlencoded::from_bytes(&body) {
Ok(x) => x,
Err(err) => return futures::finished(Response::new()
.with_status(hyper::StatusCode::BadRequest)
.with_body(format!("{:#?}", err))
).boxed()
};
let updated = match self.state.update_article(self.data.article_id, update.base_revision, &update.body) {
Ok(x) => x,
Err(x) => return futures::failed(x).boxed(),
};
futures::finished(Response::new()
.with_status(hyper::StatusCode::Ok)
.with_header(ContentType(APPLICATION_JSON.clone()))
.with_body(serde_json::to_string(&PutResponse {
revision: updated.revision,
rendered: &render_markdown(&updated.body),
created: &Local.from_utc_datetime(&updated.created).to_string(),
}).expect("Should never fail"))
).boxed()
})
.boxed()
} }
} }
@ -179,7 +225,7 @@ impl Site {
.with_status(hyper::StatusCode::NotFound) .with_status(hyper::StatusCode::NotFound)
} }
fn internal_server_error(err: Box<::std::error::Error + Send>) -> Response { fn internal_server_error(err: Box<::std::error::Error + Send + Sync>) -> Response {
eprintln!("Internal Server Error:\n{:#?}", err); eprintln!("Internal Server Error:\n{:#?}", err);
Response::new() Response::new()
@ -210,14 +256,7 @@ impl Service for Site {
Options => futures::finished(resource.options()).boxed(), Options => futures::finished(resource.options()).boxed(),
Head => resource.head(), Head => resource.head(),
Get => resource.get(), Get => resource.get(),
Put => { Put => resource.put(body),
use futures::Stream;
body
.concat2()
.map_err(|x| Box::new(x) as Box<::std::error::Error + Send>)
.and_then(move |body| resource.put(&body))
.boxed()
},
_ => futures::finished(resource.method_not_allowed()).boxed() _ => futures::finished(resource.method_not_allowed()).boxed()
} }
}, },

View file

@ -1,6 +1,7 @@
use std; use std;
use chrono; use chrono;
use diesel;
use diesel::sqlite::SqliteConnection; use diesel::sqlite::SqliteConnection;
use diesel::prelude::*; use diesel::prelude::*;
use r2d2::Pool; use r2d2::Pool;
@ -13,12 +14,6 @@ pub struct State {
connection_pool: Pool<ConnectionManager<SqliteConnection>> connection_pool: Pool<ConnectionManager<SqliteConnection>>
} }
#[derive(Deserialize)]
pub struct UpdateArticle {
base_revision: i32,
body: String,
}
pub type Error = Box<std::error::Error + Send + Sync>; pub type Error = Box<std::error::Error + Send + Sync>;
impl State { impl State {
@ -46,4 +41,52 @@ impl State {
.load::<models::ArticleRevision>(&*self.connection_pool.get()?)? .load::<models::ArticleRevision>(&*self.connection_pool.get()?)?
.pop()) .pop())
} }
pub fn update_article(&self, article_id: i32, base_revision: i32, body: &str) -> Result<models::ArticleRevision, Error> {
let conn = self.connection_pool.get()?;
conn.transaction(|| {
use schema::article_revisions;
let (latest_revision, title) = article_revisions::table
.filter(article_revisions::article_id.eq(article_id))
.order(article_revisions::revision.desc())
.limit(1)
.select((article_revisions::revision, article_revisions::title))
.load::<(i32, String)>(&*conn)?
.pop()
.unwrap_or_else(|| unimplemented!("TODO Missing an error type"));
if latest_revision != base_revision {
// TODO: If it is the same edit repeated, just respond OK
// TODO: If there is a conflict, transform the edit to work seamlessly
unimplemented!("TODO Missing handling of revision conflicts");
}
let new_revision = base_revision + 1;
#[derive(Insertable)]
#[table_name="article_revisions"]
struct NewRevision<'a> {
article_id: i32,
revision: i32,
title: &'a str,
body: &'a str,
}
diesel::insert(&NewRevision {
article_id,
revision: new_revision,
title: &title,
body
})
.into(article_revisions::table)
.execute(&*conn)?;
Ok(article_revisions::table
.filter(article_revisions::article_id.eq(article_id))
.filter(article_revisions::revision.eq(new_revision))
.load::<models::ArticleRevision>(&*conn)?
.pop()
.expect("We just inserted this row!"))
})
}
} }

View file

@ -7,13 +7,13 @@ lazy_static! {
static ref TEXT_PLAIN: mime::Mime = "text/plain;charset=utf-8".parse().unwrap(); static ref TEXT_PLAIN: mime::Mime = "text/plain;charset=utf-8".parse().unwrap();
} }
type Error = Box<std::error::Error + Send>; type Error = Box<std::error::Error + Send + Sync>;
pub trait Resource { pub trait Resource {
fn allow(&self) -> Vec<hyper::Method>; fn allow(&self) -> Vec<hyper::Method>;
fn head(&self) -> futures::BoxFuture<server::Response, Error>; fn head(&self) -> futures::BoxFuture<server::Response, Error>;
fn get(self) -> futures::BoxFuture<server::Response, Error>; fn get(self) -> futures::BoxFuture<server::Response, Error>;
fn put(self, body: &[u8]) -> futures::BoxFuture<server::Response, Error>; fn put<S: 'static + futures::Stream<Item=hyper::Chunk, Error=hyper::Error> + Send + Sync>(self, body: S) -> futures::BoxFuture<server::Response, Error>;
fn options(&self) -> Response { fn options(&self) -> Response {
Response::new() Response::new()

View file

@ -8,7 +8,7 @@
</div> </div>
<div class="editor"> <div class="editor">
<form action="" method="POST"> <form action="" method="POST">
<input type=hidden name=baseRevision value="{{revision}}"> <input type=hidden name=base_revision value="{{revision}}">
<textarea autocomplete=off name=body>{{raw}}</textarea> <textarea autocomplete=off name=body>{{raw}}</textarea>
<textarea autocomplete=off class="shadow-control"></textarea> <textarea autocomplete=off class="shadow-control"></textarea>
<div class="editor-controls"> <div class="editor-controls">
@ -26,10 +26,10 @@
<dd>{{article_id}}</dd> <dd>{{article_id}}</dd>
<dt>Revision</dt> <dt>Revision</dt>
<dd>{{revision}}</dd> <dd class="revision">{{revision}}</dd>
<dt>Last updated</dt> <dt>Last updated</dt>
<dd>{{created}}</dd> <dd class="last-updated">{{created}}</dd>
</dl> </dl>
</footer> </footer>
@ -49,22 +49,29 @@ function queryArgsFromForm(form) {
return items.join('&'); return items.join('&');
} }
let hasBeenOpen = false;
function openEditor() { function openEditor() {
const article = document.querySelector("article"); const article = document.querySelector("article");
const rendered = document.querySelector(".rendered"); const rendered = document.querySelector(".rendered");
const editor = document.querySelector(".editor"); const editor = document.querySelector(".editor");
const textarea = editor.querySelector('textarea[name="body"]'); const textarea = editor.querySelector('textarea[name="body"]');
const shadow = editor.querySelector('textarea.shadow-control'); const shadow = editor.querySelector('textarea.shadow-control');
const form = editor.querySelector("form");
textarea.style.height = rendered.clientHeight + "px"; textarea.style.height = rendered.clientHeight + "px";
article.classList.add('edit'); article.classList.add('edit');
autosizeTextarea(textarea, shadow); autosizeTextarea(textarea, shadow);
textarea.focus();
if (hasBeenOpen) return;
hasBeenOpen = true;
textarea.addEventListener('input', () => autosizeTextarea(textarea, shadow)); textarea.addEventListener('input', () => autosizeTextarea(textarea, shadow));
window.addEventListener('resize', () => autosizeTextarea(textarea, shadow)); window.addEventListener('resize', () => autosizeTextarea(textarea, shadow));
const form = editor.querySelector("form");
form.addEventListener("submit", function (ev) { form.addEventListener("submit", function (ev) {
(async function () { (async function () {
ev.preventDefault(); ev.preventDefault();
@ -86,6 +93,13 @@ function openEditor() {
if (!response.ok) throw new Error("Unexpected status code (" + response.status + ")"); if (!response.ok) throw new Error("Unexpected status code (" + response.status + ")");
const result = await response.json();
form.elements.base_revision.value = result.revision;
document.querySelector("footer .revision").textContent = result.revision;
document.querySelector("footer .last-updated").textContent = result.created;
rendered.innerHTML = result.rendered;
article.classList.remove('edit');
textarea.disabled = false; textarea.disabled = false;
}() }()
.catch(err => { .catch(err => {
@ -94,8 +108,6 @@ function openEditor() {
alert(err); alert(err);
})); }));
}); });
textarea.focus();
} }
document document