Skip to content
Open

Map #16

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,8 @@ Cow.dbmdl
Cow.sqlproj.user
obj/
Cow.jfm
gpt/
gpt/
ucm_maps/

# vscode
.vscode
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ sysinfo = "0.29.8"
proto-mc = { git = "https://github.com/DoggySazHi/proto-mc" }
# RNG
rand = "0.8.5"
pdfium-render = "0.8.16"
image = "0.24.7"

# Discord API
[dependencies.serenity]
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,10 @@
# cow
Discord bot for the UC-Mooced Discord. Written by William and Andrew in Rust.

### Building Map Download Instructions.
There's no pure-rust library for rendering pdfs to images.

You need to get pdfium from Chromium.
Either build from source or download the prebuilt .so [file](https://github.com/bblanchon/pdfium-binaries?tab=readme-ov-file)

And make sure LD_LIBRARY_PATH is set to where the .so file is (or place it in /usr/lib)
130 changes: 130 additions & 0 deletions src/commands/ucm/map.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
use std::{borrow::Cow, io::Read};

use image::EncodableLayout;
use poise::serenity_prelude::AttachmentType;
use serenity::{model::application::component::ButtonStyle, client::Context};
use serenity::model::application::interaction::message_component::MessageComponentInteraction;
use crate::{CowContext, Error};

#[poise::command(
prefix_command,
slash_command,
description_localized("en-US", "Returns building floor plans"),
)]
pub async fn map(
ctx: CowContext<'_>,
#[description = "Building name"] building: String)
-> Result<(), Error> {

let building_short_name = get_short_building_name(building.to_string());
let path = format!("ucm_maps/{}", building_short_name);

let mut entries = tokio::fs::read_dir(path).await?;
let mut max_floors = 0;
while let Some(_) = entries.next_entry().await? {
max_floors += 1;
}

let building_short_name = get_short_building_name(building.to_string());
let path = format!("ucm_maps/{}/floor_{}.jpg", building_short_name, 0);

let img = tokio::fs::read(path).await?
.bytes()
.collect::<Result<Vec<u8>, std::io::Error>>()?;

ctx.send(|m| {
m.embed(|e| {
e
.title(format!("Map Floor of {}", building))
.attachment("floor.jpg")
})
.attachment(AttachmentType::Bytes { data: Cow::from(img.as_bytes().to_owned()), filename: "floor.jpg".to_string() });

if max_floors > 1 {
m.components(|c| {
c.create_action_row(|r| {

r.create_button(|b| {
let data = format!("map_next_floor:{}:{}:{}", building, 1, max_floors);

b.style(ButtonStyle::Primary)
.label("Next Floor")
.custom_id(data)
})
})
});
}

m
}).await?;

Ok(())
}

fn get_short_building_name(building: String) -> String {
match &building[..] {
"campus" => "campus_map_recent",
"cob1" => "cob",
"glacier" => "glcr",
"granite" => "gran",
"library" => "kl",
"se1" => "s_e1",
"ssb" => "ssb_firstfloor",
_ => &building[..]
}.to_owned()
}

pub async fn map_next_floor(ctx: &Context, interaction: &mut MessageComponentInteraction) -> Result<(), Error> {
let data = interaction.data.custom_id.split(':').collect::<Vec<_>>();

let building = data[1];
let idx = str::parse::<usize>(data[2])?;
let floors = str::parse::<usize>(data[3])?;

let building_short_name = get_short_building_name(building.to_string());
let path = format!("ucm_maps/{}/floor_{}.jpg", building_short_name, idx);

let img = tokio::fs::read(path).await?
.bytes()
.collect::<Result<Vec<u8>, std::io::Error>>()?;

interaction.message.edit(ctx, |m| {
m.embed(|e| {
e
.title(format!("Map Floor of {}", building))
.attachment("floor.jpg")
})
.attachment(AttachmentType::Bytes { data: Cow::from(img.as_bytes().to_owned()), filename: "floor.jpg".to_string() });

m.components(|c| {
c.create_action_row(|r| {

if idx > 0 {
r.create_button(|b| {
let data = format!("map_next_floor:{}:{}:{}", building, idx-1, floors);

b.style(ButtonStyle::Primary)
.label("Previous Floor")
.custom_id(data)

});
}

if idx < floors-1 {
r.create_button(|b| {
let data = format!("map_next_floor:{}:{}:{}", building, idx+1, floors);

b.style(ButtonStyle::Primary)
.label("Next Floor")
.custom_id(data)

});
}

r
})
})
}).await?;

Ok(())
}
4 changes: 3 additions & 1 deletion src/commands/ucm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod foodtrucks;
mod calendar;
mod gym;
mod store;
pub(crate) mod map;

use library::*;
use courses::*;
Expand All @@ -24,10 +25,11 @@ use calendar::*;
use gym::*;
use store::*;
use reminders::*;
use map::*;
use crate::{CowContext, Error};

#[poise::command(prefix_command, slash_command,
subcommands("library", "courses", "courses_old", "pavilion", "professors", "foodtrucks", "calendar", "gym", "store", "reminders"),
subcommands("library", "courses", "courses_old", "pavilion", "professors", "foodtrucks", "calendar", "gym", "store", "reminders", "map"),
discard_spare_arguments,
description_localized("en-US", "Get information about UC Merced's services and facilities."),
aliases("ucmerced"),
Expand Down
14 changes: 13 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ impl EventHandler for Handler {
}

async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
if let Some(component) = interaction.message_component() {
if let Some(mut component) = interaction.message_component() {
if component.data.custom_id.starts_with("full_menu") {
if let Err(ex) = component.defer(&ctx).await {
error!("Failed to defer component: {}", ex);
Expand All @@ -75,6 +75,15 @@ impl EventHandler for Handler {
if let Err(ex) = commands::ucm::pavilion::print_full_menu(&ctx, &component).await {
error!("Failed to print full menu: {}", ex);
}
} else if component.data.custom_id.starts_with("map_next_floor") {
if let Err(ex) = component.defer(&ctx).await {
error!("Failed to defer component: {}", ex);
return;
}

if let Err(ex) = commands::ucm::map::map_next_floor(&ctx, &mut component).await {
error!("Failed to get next map floor: {}", ex);
}
}
}
}
Expand Down Expand Up @@ -206,6 +215,9 @@ async fn main() -> Result<(), Box<dyn error::Error>> {
}
}

// download ucm maps and save them in this directory
let _ = tokio::task::spawn(services::map_download::dl_and_convert_all_maps());

if let Err(ex) = poise.start().await {
error!("Discord bot client error: {:?}", ex);
}
Expand Down
140 changes: 140 additions & 0 deletions src/services/map_download.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
use std::{error::Error, path::Path};
use scraper::{Html, Selector};
use tokio::task::JoinSet;

use pdfium_render::prelude::*;

/* Taken straight from https://docs.rs/pdfium-render/latest/pdfium_render/ */
// sadly nothing is async. pdfium dynlib bindings struct can't be shared through Arc<Mutex<T>>
// it's already guarded behind a mutex, threading cannot improve performance: https://github.com/ajrcarey/pdfium-render/blob/master/examples/thread_safe.rs
fn export_pdf_to_jpegs(path: &impl AsRef<Path>, out_path: &str, password: Option<&str>) -> Result<(), PdfiumError> {
let pdfium = Pdfium::new(
Pdfium::bind_to_library(Pdfium::pdfium_platform_library_name_at_path("./"))
.or_else(|_| Pdfium::bind_to_system_library())?,
);

let document = pdfium.load_pdf_from_file(path, password)?;

let render_config = PdfRenderConfig::new()
.set_target_width(2000)
.set_maximum_height(2000);

for (index, page) in document.pages().iter().enumerate() {
page.render_with_config(&render_config)?
.as_image() // Renders this page to an image::DynamicImage...
.as_rgba8() // ... then converts it to an image::Image...
.ok_or(PdfiumError::ImageError)?
.save_with_format(
format!("{}/floor_{}.jpg", out_path, index),
image::ImageFormat::Jpeg
) // ... and saves it to a file.
.map_err(|_| PdfiumError::ImageError)?;
}

Ok(())
}

async fn get_map_urls() -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
let base_url = "https://registrar.ucmerced.edu/resources/maps";
let registrar_map_txt = reqwest::get(base_url).await?.text().await?;

let page = Html::parse_document(&registrar_map_txt);
let a_selector = Selector::parse("a").ok().ok_or("No <a> found")?;
let a_elements = page.select(&a_selector);

let urls_result: Result<Vec<String>, Box<dyn Error + Send + Sync>> = a_elements
.map(|a_elem| {
a_elem
.value()
.attr("href")
.ok_or("no href".into())
.map(String::from)
})
.collect();

let urls = urls_result?;
let map_urls = urls
.iter()
.filter(|url| url.contains("https://registrar.ucmerced.edu/sites/registrar.ucmerced.edu/files/page/documents"))
.cloned()
.collect::<Vec<String>>();

Ok(map_urls)
}

async fn dl_all_maps_pdf() -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
let map_urls = get_map_urls().await?;

tokio::fs::create_dir_all("ucm_maps").await?;

let mut set = JoinSet::new();

for url in map_urls {
set.spawn(async move {
dl_map(&url).await
});
}

let mut map_names = vec![];
while let Some(res) = set.join_next().await {
map_names.push(res??);
}

Ok(map_names)
}

async fn dl_map(url: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
let bytes = reqwest::get(url)
.await?
.bytes()
.await?;

let name = url.split("/").last().ok_or("invalid file name")?;

tokio::fs::write(format!("ucm_maps/{}", name), bytes).await?;

Ok(name.to_owned())
}


pub async fn dl_and_convert_all_maps() -> Result<(), Box<dyn Error + Send + Sync>> {
if tokio::fs::metadata("ucm_maps").await.is_ok() { // https://users.rust-lang.org/t/tokio-async-how-to-check-if-a-file-exists/80962/7
return Ok(());
}

let names = dl_all_maps_pdf().await?;

let mut set = JoinSet::new();

for name in names {
set.spawn(async move {
let dirname = name.split(".").next();
if let None = dirname {
return Err("bad pdf name".to_string());
}

let dirname = dirname.unwrap();

if let Err(err) = tokio::fs::create_dir_all(format!("ucm_maps/{}", dirname)).await {
return Err(err.to_string());
}

if let Err(err) = export_pdf_to_jpegs(&Path::new(&format!("ucm_maps/{}", name)), &format!("ucm_maps/{}", dirname), None) {
return Err(err.to_string());
}

Ok(())
});
}

while let Some(res) = set.join_next().await {
let _ = res?;
}

Ok(())
}

#[tokio::test]
async fn test_dl_all_maps() {
println!("{:?}", dl_and_convert_all_maps().await);
}
1 change: 1 addition & 0 deletions src/services/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod message_handler;
pub mod bot_init;
pub mod database;
pub mod map_download;
mod minecraft_db;
mod gpt_db;