diff --git a/Cargo.lock b/Cargo.lock index ac52685..f8980cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,7 @@ dependencies = [ "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_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)", "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)", @@ -572,6 +573,17 @@ dependencies = [ "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]] name = "serde_urlencoded" 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_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_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 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" diff --git a/Cargo.toml b/Cargo.toml index 8316462..8a662ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ tokio-service = "0.1" serde_derive = "1.0.0" serde = "1.0.0" serde_urlencoded = "0.5.0" +serde_json = "1.0" r2d2 = "0.7" r2d2-diesel = "0.16" regex = "0.2" diff --git a/src/main.rs b/src/main.rs index 3cc266c..b948e69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ extern crate hyper; extern crate pulldown_cmark; extern crate r2d2; extern crate r2d2_diesel; +extern crate serde_json; extern crate serde_urlencoded; use std::net::SocketAddr; diff --git a/src/site.rs b/src/site.rs index e7e5b0f..3beb245 100644 --- a/src/site.rs +++ b/src/site.rs @@ -5,6 +5,8 @@ use hyper; use hyper::header::ContentType; use hyper::mime; use hyper::server::*; +use serde_json; +use serde_urlencoded; use models; use state::State; @@ -47,6 +49,7 @@ fn render_markdown(src: &str) -> String { lazy_static! { 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)] @@ -70,7 +73,7 @@ struct WikiLookup { impl Lookup for WikiLookup { type Resource = ArticleResource; - type Error = Box<::std::error::Error + Send>; + type Error = Box<::std::error::Error + Send + Sync>; type Future = futures::future::FutureResult, Self::Error>; 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() { match self.state.get_article_revision_by_id(article_id) { Ok(Some(article)) => { - futures::finished(Some(ArticleResource::new(article))) + futures::finished(Some(ArticleResource::new(self.state.clone(), article))) }, Ok(None) => futures::finished(None), Err(err) => futures::failed(err), @@ -97,14 +100,13 @@ impl Lookup for WikiLookup { } struct ArticleResource { + state: State, data: models::ArticleRevision, } impl ArticleResource { - fn new(data: models::ArticleRevision) -> Self { - Self { - data - } + fn new(state: State, data: models::ArticleRevision) -> Self { + Self { state, data } } } @@ -114,14 +116,14 @@ impl Resource for ArticleResource { vec![Options, Head, Get, Put] } - fn head(&self) -> futures::BoxFuture> { + fn head(&self) -> futures::BoxFuture> { futures::finished(Response::new() .with_status(hyper::StatusCode::Ok) .with_header(ContentType(TEXT_HTML.clone())) ).boxed() } - fn get(self) -> futures::BoxFuture> { + fn get(self) -> futures::BoxFuture> { use chrono::{self, TimeZone, Local}; #[derive(BartDisplay)] @@ -152,8 +154,52 @@ impl Resource for ArticleResource { ).boxed() } - fn put(self, body: &[u8]) -> futures::BoxFuture> { - unimplemented!() + fn put + Send + Sync>(self, body: S) -> futures::BoxFuture> { + // 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) } - 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); Response::new() @@ -210,14 +256,7 @@ impl Service for Site { Options => futures::finished(resource.options()).boxed(), Head => resource.head(), Get => resource.get(), - Put => { - 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() - }, + Put => resource.put(body), _ => futures::finished(resource.method_not_allowed()).boxed() } }, diff --git a/src/state.rs b/src/state.rs index 7ffeb2e..307f583 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,6 +1,7 @@ use std; use chrono; +use diesel; use diesel::sqlite::SqliteConnection; use diesel::prelude::*; use r2d2::Pool; @@ -13,12 +14,6 @@ pub struct State { connection_pool: Pool> } -#[derive(Deserialize)] -pub struct UpdateArticle { - base_revision: i32, - body: String, -} - pub type Error = Box; impl State { @@ -46,4 +41,52 @@ impl State { .load::(&*self.connection_pool.get()?)? .pop()) } + + pub fn update_article(&self, article_id: i32, base_revision: i32, body: &str) -> Result { + 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::(&*conn)? + .pop() + .expect("We just inserted this row!")) + }) + } } diff --git a/src/web/resource.rs b/src/web/resource.rs index 92da2ce..1f15b20 100644 --- a/src/web/resource.rs +++ b/src/web/resource.rs @@ -7,13 +7,13 @@ lazy_static! { static ref TEXT_PLAIN: mime::Mime = "text/plain;charset=utf-8".parse().unwrap(); } -type Error = Box; +type Error = Box; pub trait Resource { fn allow(&self) -> Vec; fn head(&self) -> futures::BoxFuture; fn get(self) -> futures::BoxFuture; - fn put(self, body: &[u8]) -> futures::BoxFuture; + fn put + Send + Sync>(self, body: S) -> futures::BoxFuture; fn options(&self) -> Response { Response::new() diff --git a/templates/article_revision.html b/templates/article_revision.html index 6055288..09f9c35 100644 --- a/templates/article_revision.html +++ b/templates/article_revision.html @@ -8,7 +8,7 @@
- +
@@ -26,10 +26,10 @@
{{article_id}}
Revision
-
{{revision}}
+
{{revision}}
Last updated
-
{{created}}
+
{{created}}
@@ -49,22 +49,29 @@ function queryArgsFromForm(form) { return items.join('&'); } +let hasBeenOpen = false; function openEditor() { const article = document.querySelector("article"); const rendered = document.querySelector(".rendered"); const editor = document.querySelector(".editor"); const textarea = editor.querySelector('textarea[name="body"]'); const shadow = editor.querySelector('textarea.shadow-control'); + const form = editor.querySelector("form"); textarea.style.height = rendered.clientHeight + "px"; article.classList.add('edit'); autosizeTextarea(textarea, shadow); + + textarea.focus(); + + if (hasBeenOpen) return; + hasBeenOpen = true; + textarea.addEventListener('input', () => autosizeTextarea(textarea, shadow)); window.addEventListener('resize', () => autosizeTextarea(textarea, shadow)); - const form = editor.querySelector("form"); form.addEventListener("submit", function (ev) { (async function () { ev.preventDefault(); @@ -86,6 +93,13 @@ function openEditor() { 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; }() .catch(err => { @@ -94,8 +108,6 @@ function openEditor() { alert(err); })); }); - - textarea.focus(); } document