~whbboyd/russet

ce0c3ba8f7db1a0a30f349ef4d2cd25f1ec030a2 — Will Boyd 4 months ago 5ef3ad8
first cut at bulk operations
4 files changed, 201 insertions(+), 98 deletions(-)

M src/domain/entries.rs
M src/http/mod.rs
A src/http/root.rs
R templates/{home.stpl => root.stpl}
M src/domain/entries.rs => src/domain/entries.rs +15 -0
@@ 50,6 50,21 @@ where Persistence: RussetEntryPersistenceLayer {
			.filter(|entry| entry.as_ref().map_or_else(|_| true, |entry| !entry.tombstone))
			.collect::<Vec<Result<Entry>>>()
	}

	pub async fn set_userentries(
		&self,
		entry_ids: &Vec<EntryId>,
		user_id: &UserId,
		user_entry: &UserEntry,
	) -> Result<()> {
		for entry_id in entry_ids {
			let _ = self.persistence
				.get_entry_and_set_userentry(entry_id, user_id, user_entry)
				.await?;
		}
		Ok(())
	}

}

fn convert_entry(entry: PersistenceEntry, user_entry: Option<UserEntry>, tz: Tz) -> Entry {

M src/http/mod.rs => src/http/mod.rs +3 -55
@@ 1,17 1,11 @@
use axum::extract::{ Form, State };
use axum::middleware::map_response;
use axum::response::{ Html, Redirect, Response };
use axum::response::{ Redirect, Response };
use axum::Router;
use axum::routing::{ any, get, post };
use crate::domain::model::{ Entry, Feed };
use crate::domain::RussetDomainService;
use crate::http::session::AuthenticatedUser;
use crate::model::{ FeedId, Pagination };
use crate::persistence::model::User;
use crate::persistence::RussetPersistenceLayer;
use sailfish::TemplateOnce;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Semaphore;
use tower::limit::GlobalConcurrencyLimitLayer;


@@ 20,6 14,7 @@ use tower_http::compression::CompressionLayer;
mod entry;
mod feed;
mod login;
mod root;
mod session;
mod static_routes;
mod subscribe;


@@ 36,7 31,7 @@ where Persistence: RussetPersistenceLayer {
		.layer(GlobalConcurrencyLimitLayer::with_semaphore(login_limit_sempahore))
		.route("/login", get(login::login_page))
		.route("/styles.css", get(static_routes::styles))
		.route("/", get(home))
		.route("/", get(root::root).post(root::edit_userentries))
		.route("/entry/:id", get(entry::mark_read_redirect))
		.route("/feed/:id", get(feed::feed_page).post(feed::unsubscribe))
		.route("/subscribe", get(subscribe::subscribe_page).post(subscribe::subscribe))


@@ 67,56 62,9 @@ where Persistence: RussetPersistenceLayer {
	fn clone(&self) -> Self { AppState { domain_service: self.domain_service.clone() } }
}

// Home (root) page template
#[derive(TemplateOnce)]
#[template(path = "home.stpl")]
struct HomePageTemplate<'a> {
	user: Option<&'a User>,
	entries: &'a [Entry],
	feeds: &'a HashMap<FeedId, Feed>,
	page_num: usize,
	page_title: &'a str,
	relative_root: &'a str,
}
#[derive(Debug, Deserialize)]
struct PageQuery {
	page_num: Option<usize>,
	page_size: Option<usize>,
}
#[tracing::instrument]
async fn home<Persistence>(
	State(state): State<AppState<Persistence>>,
	user: AuthenticatedUser<Persistence>,
	Form(pagination): Form<PageQuery>,
) -> Html<String>
where Persistence: RussetPersistenceLayer {
	let page_num = pagination.page_num.unwrap_or(0);
	let page_size = pagination.page_size.unwrap_or(100);
	let pagination = Pagination { page_num, page_size };
	let entries = state.domain_service
		.get_subscribed_entries(&user.user.id, &pagination)
		.await
		.into_iter()
		.filter_map(|entry| entry.ok())
		.collect::<Vec<Entry>>();
	let feeds = state.domain_service
		.feeds_for_user(&user.user.id)
		.await
		.into_iter()
		.filter_map(|feed| feed.ok())
		.map(|feed| (feed.id.clone(), feed))
		.collect::<HashMap<FeedId, Feed>>();
	Html(
		HomePageTemplate {
			user: Some(&user.user),
			entries: entries.as_slice(),
			feeds: &feeds,
			page_num: pagination.page_num,
			page_title: "Entries",
			relative_root: "",
		}
		.render_once()
		.unwrap()
	)
}


A src/http/root.rs => src/http/root.rs +132 -0
@@ 0,0 1,132 @@
use axum::extract::{ Form, State };
use axum::http::StatusCode;
use axum::response::{ Html, Redirect };
use crate::domain::model::{ Entry, Feed };
use crate::http::{ AppState, PageQuery };
use crate::http::session::AuthenticatedUser;
use crate::model::{ EntryId, FeedId, Pagination, Timestamp };
use crate::persistence::model::{ User, UserEntry };
use crate::persistence::RussetPersistenceLayer;
use crate::Result;
use sailfish::TemplateOnce;
use std::collections::HashMap;
use std::time::SystemTime;
use tracing::error;

// Root (home/entries) page template
#[derive(TemplateOnce)]
#[template(path = "root.stpl")]
struct RootPageTemplate<'a> {
	user: Option<&'a User>,
	entries: &'a [Entry],
	feeds: &'a HashMap<FeedId, Feed>,
	page_num: usize,
	page_title: &'a str,
	relative_root: &'a str,
}
#[tracing::instrument]
pub async fn root<Persistence>(
	State(state): State<AppState<Persistence>>,
	user: AuthenticatedUser<Persistence>,
	Form(pagination): Form<PageQuery>,
) -> Html<String>
where Persistence: RussetPersistenceLayer {
	let page_num = pagination.page_num.unwrap_or(0);
	let page_size = pagination.page_size.unwrap_or(100);
	let pagination = Pagination { page_num, page_size };
	let entries = state.domain_service
		.get_subscribed_entries(&user.user.id, &pagination)
		.await
		.into_iter()
		.filter_map(|entry| entry.ok())
		.collect::<Vec<Entry>>();
	let feeds = state.domain_service
		.feeds_for_user(&user.user.id)
		.await
		.into_iter()
		.filter_map(|feed| feed.ok())
		.map(|feed| (feed.id.clone(), feed))
		.collect::<HashMap<FeedId, Feed>>();
	Html(
		RootPageTemplate {
			user: Some(&user.user),
			entries: entries.as_slice(),
			feeds: &feeds,
			page_num: pagination.page_num,
			page_title: "Entries",
			relative_root: "",
		}
		.render_once()
		.unwrap()
	)
}

#[derive(Debug)]
enum Action {
	MarkRead,
	Delete,
}
#[derive(Debug)]
pub struct EditUserEntriesRequest {
	action: Action,
	select_all: bool,
	selected_ids: Vec<EntryId>,
}
impl EditUserEntriesRequest {
	fn from_raw_entries(entries: &Vec<(String, String)>) -> Result<EditUserEntriesRequest> {
		let mut action: Option<Action> = None;
		let mut select_all = false;
		let mut selected_ids: Vec<EntryId> = Vec::new();
		for (key, value) in entries {
			match key.as_str() {
				"action" => {
					if action.is_some() {
						return Err(format!("Multiple actions: at least {action:?} and {value:?}").into())
					}
					action = Some(match value.as_str() {
						"mark_read" => Action::MarkRead,
						"delete" => Action::Delete,
						_ => return Err(format!("").into()),
					});
				},
				"select-all" => select_all = true,
				key if key.starts_with("select-") => {
					let suffix = key.strip_prefix("select-").unwrap();
					let id = ulid::Ulid::from_string(suffix)?;
					selected_ids.push(EntryId(id));
				},
				_ => return Err(format!("Bad key: {key:?}").into()),
			}
		}
		let action = action.ok_or(Into::<crate::Err>::into("No action"))?;
		Ok(EditUserEntriesRequest{
			action,
			select_all,
			selected_ids,
		})
	}
}
pub async fn edit_userentries<Persistence>(
	State(state): State<AppState<Persistence>>,
	user: AuthenticatedUser<Persistence>,
	Form(request): Form<Vec<(String, String)>>,
) -> std::result::Result<Redirect, StatusCode>
where Persistence: RussetPersistenceLayer {
	let request = EditUserEntriesRequest::from_raw_entries(&request)
		.map_err(|err| {
			error!("{err:?}");
			StatusCode::INTERNAL_SERVER_ERROR
		})?;
	let time = Some(Timestamp::new(SystemTime::now()));
	let user_entry = match request.action {
		Action::MarkRead => UserEntry { read: time, tombstone: None },
		Action::Delete => UserEntry { read: time.clone(), tombstone: time },
	};
	state.domain_service.set_userentries(&request.selected_ids, &user.user.id, &user_entry)
		.await
		.map_err(|err| {
			error!("{err:?}");
			StatusCode::INTERNAL_SERVER_ERROR
		})?;
	Ok(Redirect::to("/"))
}

R templates/home.stpl => templates/root.stpl +51 -43
@@ 1,45 1,53 @@
<% include!("head.stpl"); %>
		<form><table>
			<thead class="alt"><tr>
				<th scope="col">
					<label for="select-all">All</label>
					<input type="checkbox" name="select-all" />
				</th>
				<th scope="col">Title</th>
				<th scope="col">Date</th>
				<th scope="col">Feed</th>
			</tr></thead>
			<tbody><%
for (i, entry) in entries.iter().enumerate() {
	let mut classes = vec![];
	if i % 2 == 1 { classes.push("alt") };
	if !entry.read { classes.push("unread") };
	let classes = if classes.len() > 0 {
		format!(" class=\"{}\"", classes.join(" "))
	} else {
		"".to_string()
	};
%>
				<tr<%- classes %>>
					<td class="center"><input type="checkbox" name="select-<%= entry.id.to_string() %>" /></td>
					<td><a href="<%- relative_root %>entry/<%- entry.id.to_string() %>"><%= entry.title %></a></td>
					<td class="center"><%= entry.article_date %></td>
					<td class="center"><a href="<%- relative_root %>feed/<%= entry.feed_id.to_string() %>"><%=
match feeds.get(&entry.feed_id) {
	Some(feed) => feed.title.clone(),
	None => "Unknown Feed".to_string(),
}
%></a></td>
				</tr><%
}
%>
			</tbody>
		</table></form>
		<div>Page:
			<% if page_num > 1 { %><a href="<%- relative_root %>?page_num=0">1</a>…<% } %>
			<% if page_num > 0 { %><a href="<%- relative_root %>?page_num=<%- page_num - 1 %>"><%- page_num %></a><% } %>
			<%- page_num + 1 %>
			<a href="<%- relative_root %>?page_num=<%- page_num + 1 %>"><%- page_num + 2 %></a>
		</div>
		<div><a href="<%- relative_root %>/subscribe">Subscribe</a></div>
		<form action="<%- relative_root %>/" method="post">
			<table>
				<thead class="alt"><tr>
					<th scope="col">
						<label for="select-all">All</label>
						<input type="checkbox" name="select-all" />
					</th>
					<th scope="col">Title</th>
					<th scope="col">Date</th>
					<th scope="col">Feed</th>
				</tr></thead>
				<tbody><%
	for (i, entry) in entries.iter().enumerate() {
		let mut classes = vec![];
		if i % 2 == 1 { classes.push("alt") };
		if !entry.read { classes.push("unread") };
		let classes = if classes.len() > 0 {
			format!(" class=\"{}\"", classes.join(" "))
		} else {
			"".to_string()
		};
	%>
					<tr<%- classes %>>
						<td class="center"><input type="checkbox" name="select-<%= entry.id.to_string() %>" /></td>
						<td><a href="<%- relative_root %>entry/<%- entry.id.to_string() %>"><%= entry.title %></a></td>
						<td class="center"><%= entry.article_date %></td>
						<td class="center"><a href="<%- relative_root %>feed/<%= entry.feed_id.to_string() %>"><%=
	match feeds.get(&entry.feed_id) {
		Some(feed) => feed.title.clone(),
		None => "Unknown Feed".to_string(),
	}
	%></a></td>
					</tr><%
	}
	%>
				</tbody>
			</table>
			<div>Page:
				<% if page_num > 1 { %><a href="<%- relative_root %>?page_num=0">1</a>…<% } %>
				<% if page_num > 0 { %><a href="<%- relative_root %>?page_num=<%- page_num - 1 %>"><%- page_num %></a><% } %>
				<%- page_num + 1 %>
				<a href="<%- relative_root %>?page_num=<%- page_num + 1 %>"><%- page_num + 2 %></a>
			</div>
			<div class="dialog" style="display: flex; justify-content: center;">
				<div class="controls">
					<button name="action" value="mark_read">Mark Read</button>
					<button name="action" value="delete">Delete</button>
					<button name="action" value="subscribe" formaction="<%- relative_root %>/subscribe" formmethod="get">Subscribe</button>
				</div>
			</div>
		</form>
<% include!("foot.stpl"); %>