implement publishing / updating
This commit is contained in:
parent
b910f25cc7
commit
4995de8372
55
src/blog.rs
55
src/blog.rs
|
@ -39,7 +39,62 @@ impl PostsList {
|
||||||
// Also consumes self, as this should normally be the last action
|
// Also consumes self, as this should normally be the last action
|
||||||
// in an API call
|
// in an API call
|
||||||
pub async fn add_post(mut self, uuid: &str) -> MyResult<()> {
|
pub async fn add_post(mut self, uuid: &str) -> MyResult<()> {
|
||||||
|
if self.has_post(uuid) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
self.0.insert(0, uuid.into());
|
self.0.insert(0, uuid.into());
|
||||||
store::put_obj_pretty("posts_list", self.0).await
|
store::put_obj_pretty("posts_list", self.0).await
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Post {
|
||||||
|
// The UUID of the post (a Standard Notes UUID)
|
||||||
|
pub uuid: String,
|
||||||
|
// The UNIX timestamp (in seconds) for the post
|
||||||
|
pub timestamp: u64,
|
||||||
|
// URL of the post (relative to the root of the site)
|
||||||
|
pub url: String,
|
||||||
|
// Title of the post
|
||||||
|
pub title: String,
|
||||||
|
// The Markdown content of the post
|
||||||
|
// We keep the original content here
|
||||||
|
// so that we could make changes to the Markdown parser
|
||||||
|
// in the future; we won't be stuck with a parsed version
|
||||||
|
pub content: String
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Post {
|
||||||
|
fn uuid_to_post_key(uuid: &str) -> String {
|
||||||
|
format!("post_by_uuid_{}", uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url_to_mapping_key(url: &str) -> String {
|
||||||
|
format!("url_mapping_{}", url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_url_mapping(url: &str, uuid: &str) -> MyResult<()> {
|
||||||
|
store::put_str(&Self::url_to_mapping_key(url), uuid).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns Err(InternalError) if the post is not found
|
||||||
|
// Note that the existence status of a post here must
|
||||||
|
// be synchronized with the PostsList; that is, if a
|
||||||
|
// post is not found in PostsList, it must not be found
|
||||||
|
// here either; if a post is found in PostsList, then
|
||||||
|
// this method should not return any error.
|
||||||
|
pub async fn find_by_uuid(uuid: &str) -> MyResult<Post> {
|
||||||
|
store::get_obj(&Self::uuid_to_post_key(uuid)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the Post to KV storage; this can be a new post or
|
||||||
|
// update to an existing post; either way, the CALLER is
|
||||||
|
// responsible for making sure PostsList is updated with the
|
||||||
|
// latest set of posts sorted in order.
|
||||||
|
// This function will also create a mapping from URL to UUID in the KV
|
||||||
|
pub async fn write_to_kv(self) -> MyResult<()> {
|
||||||
|
Self::create_url_mapping(&self.url, &self.uuid).await?;
|
||||||
|
store::put_obj(&Self::uuid_to_post_key(&self.uuid), self).await
|
||||||
|
}
|
||||||
}
|
}
|
92
src/sn.rs
92
src/sn.rs
|
@ -2,12 +2,15 @@
|
||||||
use crate::{CONFIG, blog};
|
use crate::{CONFIG, blog};
|
||||||
use crate::router::Router;
|
use crate::router::Router;
|
||||||
use crate::utils::*;
|
use crate::utils::*;
|
||||||
use serde::{Serialize, Serializer};
|
use js_sys::Date;
|
||||||
|
use serde::{Deserialize, Serialize, Serializer};
|
||||||
use std::vec::Vec;
|
use std::vec::Vec;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
use web_sys::*;
|
use web_sys::*;
|
||||||
|
|
||||||
pub fn build_routes(router: &mut Router) {
|
pub fn build_routes(router: &mut Router) {
|
||||||
router.add_route("/actions", &get_actions);
|
router.add_route("/actions", &get_actions);
|
||||||
|
router.add_route("/post", &create_or_update_post);
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! verify_secret {
|
macro_rules! verify_secret {
|
||||||
|
@ -29,13 +32,16 @@ async fn get_actions(_req: Request, url: Url) -> MyResult<Response> {
|
||||||
let mut actions = vec![];
|
let mut actions = vec![];
|
||||||
|
|
||||||
// Show different options depending on whether the post already exists
|
// Show different options depending on whether the post already exists
|
||||||
let post_exists = match params.get("item_uuid") {
|
// Use Post here because PostsList is larger to read into memory
|
||||||
Some(uuid) => {
|
// also slower to check one-by-one
|
||||||
let posts = blog::PostsList::load().await;
|
let post = match params.get("item_uuid") {
|
||||||
posts.has_post(&uuid)
|
Some(uuid) => match blog::Post::find_by_uuid(&uuid).await {
|
||||||
|
Ok(post) => Some(post),
|
||||||
|
Err(_) => None
|
||||||
},
|
},
|
||||||
None => false
|
None => None
|
||||||
};
|
};
|
||||||
|
let post_exists = post.is_some();
|
||||||
|
|
||||||
actions.push(Action {
|
actions.push(Action {
|
||||||
label: if post_exists { "Update".into() } else { "Publish".into() },
|
label: if post_exists { "Update".into() } else { "Publish".into() },
|
||||||
|
@ -66,6 +72,62 @@ async fn get_actions(_req: Request, url: Url) -> MyResult<Response> {
|
||||||
).internal_err()
|
).internal_err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_or_update_post(req: Request, url: Url) -> MyResult<Response> {
|
||||||
|
verify_secret!(url, params);
|
||||||
|
if req.method() != "POST" {
|
||||||
|
return Err(Error::BadRequest("Unsupported method".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the information sent as POST body
|
||||||
|
let data: ActionsPostData = serde_json::from_str(
|
||||||
|
&JsFuture::from(req.text().internal_err()?)
|
||||||
|
.await.internal_err()?
|
||||||
|
.as_string().ok_or(Error::BadRequest("Unable to parse POST body".into()))?
|
||||||
|
).internal_err()?;
|
||||||
|
if data.items.len() == 0 {
|
||||||
|
return Err(Error::BadRequest("At least one item must be supplied".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: we should support customizing timestamp and URL from text
|
||||||
|
// and if there are option detected in text, it always overrides
|
||||||
|
// whatever was stored before
|
||||||
|
// If the URL is changed via this way, we should make sure the old
|
||||||
|
// URL actually 301's to the new URL
|
||||||
|
let uuid = data.items[0].uuid.clone();
|
||||||
|
let text = data.items[0].content.text.clone();
|
||||||
|
let title = data.items[0].content.title.clone();
|
||||||
|
let post = match blog::Post::find_by_uuid(&uuid).await {
|
||||||
|
Ok(mut post) => {
|
||||||
|
post.content = text;
|
||||||
|
post.title = title;
|
||||||
|
post
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
blog::Post {
|
||||||
|
url: title_to_url(&uuid, &title),
|
||||||
|
uuid: uuid,
|
||||||
|
title: title,
|
||||||
|
content: text,
|
||||||
|
timestamp: Date::now() as u64 / 1000 // Seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write the new post to storage
|
||||||
|
// As you may have seen by now, the process is far from atomic
|
||||||
|
// This is fine because we don't expect users to update posts from
|
||||||
|
// multiple endpoints simultaneously all the time
|
||||||
|
blog::PostsList::load().await.add_post(&post.uuid).await?;
|
||||||
|
post.write_to_kv().await?;
|
||||||
|
|
||||||
|
Response::new_with_opt_str_and_init(
|
||||||
|
None,
|
||||||
|
ResponseInit::new()
|
||||||
|
.status(200)
|
||||||
|
.headers(headers!().add_cors().as_ref())
|
||||||
|
).internal_err()
|
||||||
|
}
|
||||||
|
|
||||||
pub enum Verb {
|
pub enum Verb {
|
||||||
Show,
|
Show,
|
||||||
Post,
|
Post,
|
||||||
|
@ -149,4 +211,22 @@ pub struct ActionsExtension {
|
||||||
content_type: ContentType,
|
content_type: ContentType,
|
||||||
supported_types: Vec<ContentType>,
|
supported_types: Vec<ContentType>,
|
||||||
actions: Vec<Action>
|
actions: Vec<Action>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Many fields are omitted here since we don't use them for now
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ActionsPostItem {
|
||||||
|
uuid: String,
|
||||||
|
content: ActionsPostContent
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ActionsPostContent {
|
||||||
|
title: String,
|
||||||
|
text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ActionsPostData {
|
||||||
|
items: Vec<ActionsPostItem>
|
||||||
}
|
}
|
20
src/utils.rs
20
src/utils.rs
|
@ -41,6 +41,26 @@ macro_rules! headers(
|
||||||
() => { ::web_sys::Headers::new().unwrap() };
|
() => { ::web_sys::Headers::new().unwrap() };
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Remove all non-ascii characters from string
|
||||||
|
pub fn filter_non_ascii(s: &str) -> String {
|
||||||
|
s.chars().into_iter()
|
||||||
|
.filter(|c| c.is_ascii())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A URL is "<uuid_first_four_chars>/<title_without_non_ascii>"
|
||||||
|
// The UUID involvement is to reduce the chance that two
|
||||||
|
// articles have the same URL by having the same title when
|
||||||
|
// all non-ASCII characters are removed
|
||||||
|
pub fn title_to_url(uuid: &str, title: &str) -> String {
|
||||||
|
let title_part = filter_non_ascii(title)
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.join("-")
|
||||||
|
.to_lowercase();
|
||||||
|
format!("{}/{}", &uuid[0..4], title_part)
|
||||||
|
}
|
||||||
|
|
||||||
pub trait HeadersExt {
|
pub trait HeadersExt {
|
||||||
fn add_cors(self) -> Self;
|
fn add_cors(self) -> Self;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue